diff --git a/codex/economy/_f2p-alch-catalog.yaml b/codex/economy/_f2p-alch-catalog.yaml new file mode 100644 index 0000000..8c89f69 --- /dev/null +++ b/codex/economy/_f2p-alch-catalog.yaml @@ -0,0 +1,2435 @@ +# Dataset: F2P alch catalog (machine-readable) +# Generated 2026-08-27 from prices.runescape.wiki mapping + latest. Source of truth +# for "rank by cheapest/least-loss" in low-alch-f2p and low-alch-members. +# Validator intentionally skips files starting with "_". +fetched: '2026-08-27' +source: https://prices.runescape.wiki/api/v1/osrs/mapping + /api/v1/osrs/latest (live, at implement time) +filter: lowalch != null, members=false, ge limit >= 150 +sorted_by: buy (latest.low) ascending +note: buy = latest.low, sell = latest.high (per repo data-sources.md convention). net_per_cycle = lowalch_price - buy; nature/fire runes NOT included (staff of fire reduces rune cost to 1 nature/cast). +entries: 303 +items: +- item: Pure essence + id: 7936 + buy: 1 + sell: 2 + limit: 30000 + lowalch_price: 1 + value: 4 + net_per_cycle: 0 +- item: Bronze bolts + id: 877 + buy: 2 + sell: 3 + limit: 7000 + lowalch_price: 1 + value: 0 + net_per_cycle: -1 +- item: Feather + id: 314 + buy: 2 + sell: 3 + limit: 30000 + lowalch_price: 1 + value: 2 + net_per_cycle: -1 +- item: Mind rune + id: 558 + buy: 2 + sell: 3 + limit: 18000 + lowalch_price: 1 + value: 3 + net_per_cycle: -1 +- item: Vial + id: 229 + buy: 2 + sell: 3 + limit: 13000 + lowalch_price: 1 + value: 2 + net_per_cycle: -1 +- item: Jug + id: 1935 + buy: 3 + sell: 3 + limit: 13000 + lowalch_price: 1 + value: 1 + net_per_cycle: -2 +- item: Jug of wine + id: 1993 + buy: 3 + sell: 4 + limit: 6000 + lowalch_price: 1 + value: 1 + net_per_cycle: -2 +- item: Team-3 cape + id: 4319 + buy: 3 + sell: 200 + limit: 150 + lowalch_price: 20 + value: 50 + net_per_cycle: 17 +- item: Body rune + id: 559 + buy: 4 + sell: 5 + limit: 18000 + lowalch_price: 1 + value: 3 + net_per_cycle: -3 +- item: Bronze arrow + id: 882 + buy: 4 + sell: 4 + limit: 7000 + lowalch_price: 1 + value: 1 + net_per_cycle: -3 +- item: Eye of newt + id: 221 + buy: 4 + sell: 4 + limit: 13000 + lowalch_price: 1 + value: 3 + net_per_cycle: -3 +- item: Fire rune + id: 554 + buy: 4 + sell: 5 + limit: 50000 + lowalch_price: 1 + value: 4 + net_per_cycle: -3 +- item: Vial of water + id: 227 + buy: 4 + sell: 5 + limit: 13000 + lowalch_price: 1 + value: 2 + net_per_cycle: -3 +- item: Air rune + id: 556 + buy: 5 + sell: 6 + limit: 50000 + lowalch_price: 1 + value: 4 + net_per_cycle: -4 +- item: Earth rune + id: 557 + buy: 5 + sell: 6 + limit: 50000 + lowalch_price: 1 + value: 4 + net_per_cycle: -4 +- item: Iron arrow + id: 884 + buy: 5 + sell: 4 + limit: 7000 + lowalch_price: 1 + value: 3 + net_per_cycle: -4 +- item: Team-13 cape + id: 4339 + buy: 5 + sell: 159 + limit: 150 + lowalch_price: 20 + value: 50 + net_per_cycle: 15 +- item: Team-23 cape + id: 4359 + buy: 5 + sell: 229 + limit: 150 + lowalch_price: 20 + value: 50 + net_per_cycle: 15 +- item: Team-47 cape + id: 4407 + buy: 5 + sell: 266 + limit: 150 + lowalch_price: 20 + value: 50 + net_per_cycle: 15 +- item: Water rune + id: 555 + buy: 5 + sell: 6 + limit: 50000 + lowalch_price: 1 + value: 4 + net_per_cycle: -4 +- item: Fishing bait + id: 313 + buy: 6 + sell: 6 + limit: 8000 + lowalch_price: 1 + value: 3 + net_per_cycle: -5 +- item: Mithril arrow + id: 888 + buy: 6 + sell: 7 + limit: 7000 + lowalch_price: 12 + value: 32 + net_per_cycle: 6 +- item: Thread + id: 1734 + buy: 6 + sell: 7 + limit: 18000 + lowalch_price: 1 + value: 1 + net_per_cycle: -5 +- item: Grain + id: 1947 + buy: 7 + sell: 8 + limit: 13000 + lowalch_price: 1 + value: 2 + net_per_cycle: -6 +- item: Bucket + id: 1925 + buy: 8 + sell: 8 + limit: 13000 + lowalch_price: 1 + value: 2 + net_per_cycle: -7 +- item: Steel arrow + id: 886 + buy: 8 + sell: 8 + limit: 7000 + lowalch_price: 4 + value: 12 + net_per_cycle: -4 +- item: Black skirt + id: 1015 + buy: 10 + sell: 10 + limit: 150 + lowalch_price: 1 + value: 2 + net_per_cycle: -9 +- item: Copper ore + id: 436 + buy: 10 + sell: 11 + limit: 13000 + lowalch_price: 1 + value: 3 + net_per_cycle: -9 +- item: Maple logs + id: 1517 + buy: 10 + sell: 11 + limit: 15000 + lowalch_price: 32 + value: 80 + net_per_cycle: 22 +- item: Team-15 cape + id: 4343 + buy: 10 + sell: 177 + limit: 150 + lowalch_price: 20 + value: 50 + net_per_cycle: 10 +- item: Unfired pot + id: 1787 + buy: 10 + sell: 102 + limit: 10000 + lowalch_price: 1 + value: 1 + net_per_cycle: -9 +- item: Pot + id: 1931 + buy: 11 + sell: 18 + limit: 13000 + lowalch_price: 1 + value: 1 + net_per_cycle: -10 +- item: Priest gown (top) + id: 426 + buy: 11 + sell: 25 + limit: 150 + lowalch_price: 2 + value: 5 + net_per_cycle: -9 +- item: Rune essence + id: 1436 + buy: 11 + sell: 12 + limit: 20000 + lowalch_price: 1 + value: 4 + net_per_cycle: -10 +- item: Sardine + id: 325 + buy: 12 + sell: 13 + limit: 6000 + lowalch_price: 4 + value: 10 + net_per_cycle: -8 +- item: Silk + id: 950 + buy: 12 + sell: 14 + limit: 18000 + lowalch_price: 12 + value: 30 + net_per_cycle: 0 +- item: Bowl + id: 1923 + buy: 13 + sell: 21 + limit: 13000 + lowalch_price: 1 + value: 4 + net_per_cycle: -12 +- item: Raw sardine + id: 327 + buy: 13 + sell: 15 + limit: 15000 + lowalch_price: 4 + value: 10 + net_per_cycle: -9 +- item: Tin ore + id: 438 + buy: 13 + sell: 14 + limit: 13000 + lowalch_price: 1 + value: 3 + net_per_cycle: -12 +- item: Jug of water + id: 1937 + buy: 14 + sell: 15 + limit: 13000 + lowalch_price: 1 + value: 1 + net_per_cycle: -13 +- item: Trout + id: 333 + buy: 17 + sell: 18 + limit: 6000 + lowalch_price: 4 + value: 10 + net_per_cycle: -13 +- item: Raw anchovies + id: 321 + buy: 18 + sell: 19 + limit: 13000 + lowalch_price: 6 + value: 15 + net_per_cycle: -12 +- item: Beer glass + id: 1919 + buy: 20 + sell: 24 + limit: 10000 + lowalch_price: 1 + value: 2 + net_per_cycle: -19 +- item: Shrimps + id: 315 + buy: 20 + sell: 20 + limit: 6000 + lowalch_price: 2 + value: 5 + net_per_cycle: -18 +- item: Willow logs + id: 1519 + buy: 20 + sell: 21 + limit: 15000 + lowalch_price: 16 + value: 40 + net_per_cycle: -4 +- item: Longbow + id: 839 + buy: 21 + sell: 31 + limit: 18000 + lowalch_price: 32 + value: 80 + net_per_cycle: 11 +- item: Oak shortbow + id: 843 + buy: 21 + sell: 30 + limit: 18000 + lowalch_price: 40 + value: 100 + net_per_cycle: 19 +- item: Logs + id: 1511 + buy: 22 + sell: 23 + limit: 15000 + lowalch_price: 1 + value: 4 + net_per_cycle: -21 +- item: Onion + id: 1957 + buy: 22 + sell: 22 + limit: 13000 + lowalch_price: 1 + value: 3 + net_per_cycle: -21 +- item: Tomato + id: 1982 + buy: 22 + sell: 22 + limit: 13000 + lowalch_price: 1 + value: 4 + net_per_cycle: -21 +- item: Wool + id: 1737 + buy: 22 + sell: 22 + limit: 13000 + lowalch_price: 1 + value: 1 + net_per_cycle: -21 +- item: Blue skirt + id: 1011 + buy: 25 + sell: 38 + limit: 150 + lowalch_price: 1 + value: 2 + net_per_cycle: -24 +- item: Cabbage + id: 1965 + buy: 25 + sell: 26 + limit: 6000 + lowalch_price: 1 + value: 1 + net_per_cycle: -24 +- item: Chocolate bar + id: 1973 + buy: 25 + sell: 28 + limit: 13000 + lowalch_price: 4 + value: 10 + net_per_cycle: -21 +- item: Cooking apple + id: 1955 + buy: 25 + sell: 26 + limit: 13000 + lowalch_price: 1 + value: 1 + net_per_cycle: -24 +- item: Bucket of water + id: 1929 + buy: 26 + sell: 28 + limit: 13000 + lowalch_price: 2 + value: 6 + net_per_cycle: -24 +- item: Garlic + id: 1550 + buy: 26 + sell: 30 + limit: 11000 + lowalch_price: 1 + value: 3 + net_per_cycle: -25 +- item: Raw chicken + id: 2138 + buy: 26 + sell: 27 + limit: 13000 + lowalch_price: 1 + value: 1 + net_per_cycle: -25 +- item: Raw salmon + id: 331 + buy: 26 + sell: 29 + limit: 15000 + lowalch_price: 12 + value: 30 + net_per_cycle: -14 +- item: Potato + id: 1942 + buy: 27 + sell: 27 + limit: 13000 + lowalch_price: 1 + value: 1 + net_per_cycle: -26 +- item: Rope + id: 954 + buy: 27 + sell: 34 + limit: 250 + lowalch_price: 7 + value: 18 + net_per_cycle: -20 +- item: Salmon + id: 329 + buy: 27 + sell: 29 + limit: 6000 + lowalch_price: 12 + value: 30 + net_per_cycle: -15 +- item: Adamant arrow + id: 890 + buy: 28 + sell: 26 + limit: 11000 + lowalch_price: 32 + value: 80 + net_per_cycle: 4 +- item: Egg + id: 1944 + buy: 28 + sell: 28 + limit: 13000 + lowalch_price: 1 + value: 4 + net_per_cycle: -27 +- item: Raw shrimps + id: 317 + buy: 28 + sell: 29 + limit: 15000 + lowalch_price: 2 + value: 5 + net_per_cycle: -26 +- item: Bowl of water + id: 1921 + buy: 30 + sell: 42 + limit: 13000 + lowalch_price: 1 + value: 4 + net_per_cycle: -29 +- item: Strength potion(1) + id: 119 + buy: 30 + sell: 25 + limit: 2000 + lowalch_price: 4 + value: 11 + net_per_cycle: -26 +- item: Team-28 cape + id: 4369 + buy: 30 + sell: 101 + limit: 150 + lowalch_price: 20 + value: 50 + net_per_cycle: -10 +- item: Team-34 cape + id: 4381 + buy: 30 + sell: 812 + limit: 150 + lowalch_price: 20 + value: 50 + net_per_cycle: -10 +- item: Team-39 cape + id: 4391 + buy: 30 + sell: 150 + limit: 150 + lowalch_price: 20 + value: 50 + net_per_cycle: -10 +- item: Team-43 cape + id: 4399 + buy: 30 + sell: 229 + limit: 150 + lowalch_price: 20 + value: 50 + net_per_cycle: -10 +- item: Meat pie + id: 2327 + buy: 31 + sell: 33 + limit: 10000 + lowalch_price: 6 + value: 15 + net_per_cycle: -25 +- item: Priest gown (bottom) + id: 428 + buy: 31 + sell: 34 + limit: 150 + lowalch_price: 2 + value: 5 + net_per_cycle: -29 +- item: Herring + id: 347 + buy: 34 + sell: 14 + limit: 6000 + lowalch_price: 4 + value: 10 + net_per_cycle: -30 +- item: Bucket of milk + id: 1927 + buy: 35 + sell: 35 + limit: 13000 + lowalch_price: 2 + value: 6 + net_per_cycle: -33 +- item: Steel nails + id: 1539 + buy: 35 + sell: 35 + limit: 13000 + lowalch_price: 1 + value: 3 + net_per_cycle: -34 +- item: Anchovies + id: 319 + buy: 37 + sell: 37 + limit: 15000 + lowalch_price: 6 + value: 15 + net_per_cycle: -31 +- item: Pie dish + id: 2313 + buy: 37 + sell: 39 + limit: 500 + lowalch_price: 1 + value: 3 + net_per_cycle: -36 +- item: White apron + id: 1005 + buy: 37 + sell: 49 + limit: 150 + lowalch_price: 1 + value: 2 + net_per_cycle: -36 +- item: Bones + id: 526 + buy: 38 + sell: 40 + limit: 3000 + lowalch_price: 1 + value: 1 + net_per_cycle: -37 +- item: Bear fur + id: 948 + buy: 39 + sell: 44 + limit: 18000 + lowalch_price: 4 + value: 10 + net_per_cycle: -35 +- item: Ashes + id: 592 + buy: 40 + sell: 41 + limit: 13000 + lowalch_price: 1 + value: 2 + net_per_cycle: -39 +- item: Fiendish ashes + id: 25766 + buy: 40 + sell: 40 + limit: 3000 + lowalch_price: 1 + value: 1 + net_per_cycle: -39 +- item: Oak logs + id: 1521 + buy: 40 + sell: 42 + limit: 15000 + lowalch_price: 8 + value: 20 + net_per_cycle: -32 +- item: Oak longbow + id: 845 + buy: 40 + sell: 44 + limit: 18000 + lowalch_price: 64 + value: 160 + net_per_cycle: 24 +- item: Raw rat meat + id: 2134 + buy: 40 + sell: 50 + limit: 13000 + lowalch_price: 1 + value: 1 + net_per_cycle: -39 +- item: Raw trout + id: 335 + buy: 40 + sell: 48 + limit: 15000 + lowalch_price: 4 + value: 10 + net_per_cycle: -36 +- item: Unblessed symbol + id: 1716 + buy: 40 + sell: 38 + limit: 10000 + lowalch_price: 80 + value: 200 + net_per_cycle: 40 +- item: Unstrung symbol + id: 1714 + buy: 40 + sell: 40 + limit: 10000 + lowalch_price: 80 + value: 200 + net_per_cycle: 40 +- item: Raw beef + id: 2132 + buy: 42 + sell: 44 + limit: 13000 + lowalch_price: 1 + value: 1 + net_per_cycle: -41 +- item: Raw tuna + id: 359 + buy: 43 + sell: 44 + limit: 15000 + lowalch_price: 16 + value: 40 + net_per_cycle: -27 +- item: Strength potion(2) + id: 117 + buy: 43 + sell: 43 + limit: 2000 + lowalch_price: 5 + value: 13 + net_per_cycle: -38 +- item: Tuna + id: 361 + buy: 45 + sell: 45 + limit: 6000 + lowalch_price: 16 + value: 40 + net_per_cycle: -29 +- item: Energy potion(1) + id: 3014 + buy: 46 + sell: 46 + limit: 2000 + lowalch_price: 14 + value: 36 + net_per_cycle: -32 +- item: Pike + id: 351 + buy: 46 + sell: 71 + limit: 6000 + lowalch_price: 8 + value: 20 + net_per_cycle: -38 +- item: Chocolate dust + id: 1975 + buy: 49 + sell: 54 + limit: 13000 + lowalch_price: 1 + value: 2 + net_per_cycle: -48 +- item: Blue cape + id: 1021 + buy: 50 + sell: 109 + limit: 150 + lowalch_price: 12 + value: 32 + net_per_cycle: -38 +- item: Red cape + id: 1007 + buy: 50 + sell: 50 + limit: 150 + lowalch_price: 1 + value: 2 + net_per_cycle: -49 +- item: Shortbow + id: 841 + buy: 50 + sell: 52 + limit: 18000 + lowalch_price: 20 + value: 50 + net_per_cycle: -30 +- item: Team-20 cape + id: 4353 + buy: 50 + sell: 194 + limit: 150 + lowalch_price: 20 + value: 50 + net_per_cycle: -30 +- item: Team-36 cape + id: 4385 + buy: 50 + sell: 57 + limit: 150 + lowalch_price: 20 + value: 50 + net_per_cycle: -30 +- item: Banana + id: 1963 + buy: 51 + sell: 51 + limit: 13000 + lowalch_price: 1 + value: 2 + net_per_cycle: -50 +- item: Redberries + id: 1951 + buy: 54 + sell: 56 + limit: 13000 + lowalch_price: 1 + value: 3 + net_per_cycle: -53 +- item: Cheese + id: 1985 + buy: 55 + sell: 55 + limit: 13000 + lowalch_price: 1 + value: 4 + net_per_cycle: -54 +- item: Team-7 cape + id: 4327 + buy: 55 + sell: 245 + limit: 150 + lowalch_price: 20 + value: 50 + net_per_cycle: -35 +- item: Ball of wool + id: 1759 + buy: 57 + sell: 57 + limit: 13000 + lowalch_price: 1 + value: 2 + net_per_cycle: -56 +- item: Bronze bar + id: 2349 + buy: 60 + sell: 64 + limit: 10000 + lowalch_price: 3 + value: 8 + net_per_cycle: -57 +- item: Bread + id: 2309 + buy: 64 + sell: 65 + limit: 6000 + lowalch_price: 4 + value: 12 + net_per_cycle: -60 +- item: Raw pike + id: 349 + buy: 66 + sell: 71 + limit: 15000 + lowalch_price: 8 + value: 20 + net_per_cycle: -58 +- item: Team-6 cape + id: 4325 + buy: 66 + sell: 66 + limit: 150 + lowalch_price: 20 + value: 50 + net_per_cycle: -46 +- item: Apple pie + id: 2323 + buy: 67 + sell: 75 + limit: 10000 + lowalch_price: 12 + value: 30 + net_per_cycle: -55 +- item: Cooked chicken + id: 2140 + buy: 67 + sell: 69 + limit: 6000 + lowalch_price: 1 + value: 4 + net_per_cycle: -66 +- item: Cooked t-bone steak + id: 33109 + buy: 68 + sell: 79 + limit: 6000 + lowalch_price: 4 + value: 10 + net_per_cycle: -64 +- item: Kebab + id: 1971 + buy: 68 + sell: 70 + limit: 6000 + lowalch_price: 1 + value: 3 + net_per_cycle: -67 +- item: Beer + id: 1917 + buy: 70 + sell: 78 + limit: 2000 + lowalch_price: 1 + value: 2 + net_per_cycle: -69 +- item: Clay + id: 434 + buy: 70 + sell: 64 + limit: 13000 + lowalch_price: 1 + value: 1 + net_per_cycle: -69 +- item: Cooked meat + id: 2142 + buy: 70 + sell: 71 + limit: 6000 + lowalch_price: 1 + value: 4 + net_per_cycle: -69 +- item: Grapes + id: 1987 + buy: 70 + sell: 67 + limit: 20000 + lowalch_price: 1 + value: 1 + net_per_cycle: -69 +- item: Iron ore + id: 440 + buy: 70 + sell: 70 + limit: 13000 + lowalch_price: 6 + value: 17 + net_per_cycle: -64 +- item: Willow shortbow + id: 849 + buy: 70 + sell: 100 + limit: 18000 + lowalch_price: 80 + value: 200 + net_per_cycle: 10 +- item: Strength potion(3) + id: 115 + buy: 72 + sell: 72 + limit: 2000 + lowalch_price: 5 + value: 13 + net_per_cycle: -67 +- item: Silver ore + id: 442 + buy: 73 + sell: 79 + limit: 13000 + lowalch_price: 30 + value: 75 + net_per_cycle: -43 +- item: Team-16 cape + id: 4345 + buy: 75 + sell: 46 + limit: 150 + lowalch_price: 20 + value: 50 + net_per_cycle: -55 +- item: Team-44 cape + id: 4401 + buy: 75 + sell: 134 + limit: 150 + lowalch_price: 20 + value: 50 + net_per_cycle: -55 +- item: Yellow cape + id: 1023 + buy: 75 + sell: 93 + limit: 150 + lowalch_price: 12 + value: 32 + net_per_cycle: -63 +- item: Pot of flour + id: 1933 + buy: 80 + sell: 73 + limit: 13000 + lowalch_price: 4 + value: 10 + net_per_cycle: -76 +- item: Team-46 cape + id: 4405 + buy: 81 + sell: 78 + limit: 150 + lowalch_price: 20 + value: 50 + net_per_cycle: -61 +- item: Energy potion(2) + id: 3012 + buy: 84 + sell: 86 + limit: 2000 + lowalch_price: 28 + value: 72 + net_per_cycle: -56 +- item: Gold bar + id: 2357 + buy: 86 + sell: 89 + limit: 10000 + lowalch_price: 120 + value: 300 + net_per_cycle: 34 +- item: Unfired pie dish + id: 1789 + buy: 87 + sell: 101 + limit: 10000 + lowalch_price: 1 + value: 3 + net_per_cycle: -86 +- item: Pink skirt + id: 1013 + buy: 90 + sell: 86 + limit: 150 + lowalch_price: 1 + value: 2 + net_per_cycle: -89 +- item: Lobster + id: 379 + buy: 95 + sell: 97 + limit: 6000 + lowalch_price: 28 + value: 70 + net_per_cycle: -67 +- item: Woad leaf + id: 1793 + buy: 95 + sell: 95 + limit: 11000 + lowalch_price: 1 + value: 1 + net_per_cycle: -94 +- item: Black toy horsey + id: 2524 + buy: 100 + sell: 130 + limit: 150 + lowalch_price: 40 + value: 100 + net_per_cycle: -60 +- item: Cadava berries + id: 753 + buy: 100 + sell: 100 + limit: 600 + lowalch_price: 1 + value: 1 + net_per_cycle: -99 +- item: Team-2 cape + id: 4317 + buy: 100 + sell: 9999 + limit: 150 + lowalch_price: 20 + value: 50 + net_per_cycle: -80 +- item: Team-45 cape + id: 4403 + buy: 100 + sell: 744 + limit: 150 + lowalch_price: 20 + value: 50 + net_per_cycle: -80 +- item: Team-50 cape + id: 4413 + buy: 100 + sell: 138 + limit: 150 + lowalch_price: 20 + value: 50 + net_per_cycle: -80 +- item: Team-10 cape + id: 4333 + buy: 101 + sell: 689 + limit: 150 + lowalch_price: 20 + value: 50 + net_per_cycle: -81 +- item: Willow longbow + id: 847 + buy: 101 + sell: 110 + limit: 18000 + lowalch_price: 128 + value: 320 + net_per_cycle: 27 +- item: Pizza base + id: 2283 + buy: 105 + sell: 144 + limit: 11000 + lowalch_price: 1 + value: 4 + net_per_cycle: -104 +- item: Tiara + id: 5525 + buy: 105 + sell: 150 + limit: 10000 + lowalch_price: 40 + value: 100 + net_per_cycle: -65 +- item: Chaos rune + id: 562 + buy: 106 + sell: 107 + limit: 18000 + lowalch_price: 36 + value: 90 + net_per_cycle: -70 +- item: Strength potion(4) + id: 113 + buy: 106 + sell: 110 + limit: 2000 + lowalch_price: 5 + value: 14 + net_per_cycle: -101 +- item: Raw lobster + id: 377 + buy: 110 + sell: 110 + limit: 15000 + lowalch_price: 28 + value: 70 + net_per_cycle: -82 +- item: Dwarven stout + id: 1913 + buy: 111 + sell: 100 + limit: 2000 + lowalch_price: 1 + value: 2 + net_per_cycle: -110 +- item: Yew logs + id: 1515 + buy: 111 + sell: 112 + limit: 12000 + lowalch_price: 64 + value: 160 + net_per_cycle: -47 +- item: Silver bar + id: 2355 + buy: 113 + sell: 117 + limit: 10000 + lowalch_price: 60 + value: 150 + net_per_cycle: -53 +- item: Raw herring + id: 345 + buy: 116 + sell: 116 + limit: 15000 + lowalch_price: 4 + value: 10 + net_per_cycle: -112 +- item: Soft clay + id: 1761 + buy: 116 + sell: 116 + limit: 13000 + lowalch_price: 1 + value: 2 + net_per_cycle: -115 +- item: Wizard's mind bomb + id: 1907 + buy: 116 + sell: 400 + limit: 2000 + lowalch_price: 1 + value: 2 + net_per_cycle: -115 +- item: Team-9 cape + id: 4331 + buy: 121 + sell: 121 + limit: 150 + lowalch_price: 20 + value: 50 + net_per_cycle: -101 +- item: Law rune + id: 563 + buy: 124 + sell: 125 + limit: 18000 + lowalch_price: 96 + value: 240 + net_per_cycle: -28 +- item: Bread dough + id: 2307 + buy: 125 + sell: 135 + limit: 13000 + lowalch_price: 1 + value: 4 + net_per_cycle: -124 +- item: Cosmic rune + id: 564 + buy: 126 + sell: 128 + limit: 18000 + lowalch_price: 20 + value: 50 + net_per_cycle: -106 +- item: Energy potion(3) + id: 3010 + buy: 126 + sell: 126 + limit: 2000 + lowalch_price: 44 + value: 110 + net_per_cycle: -82 +- item: Stew + id: 2003 + buy: 127 + sell: 129 + limit: 10000 + lowalch_price: 8 + value: 20 + net_per_cycle: -119 +- item: Raw bear meat + id: 2136 + buy: 130 + sell: 170 + limit: 13000 + lowalch_price: 1 + value: 1 + net_per_cycle: -129 +- item: Vile ashes + id: 25769 + buy: 130 + sell: 131 + limit: 3000 + lowalch_price: 1 + value: 1 + net_per_cycle: -129 +- item: Pastry dough + id: 1953 + buy: 132 + sell: 137 + limit: 13000 + lowalch_price: 1 + value: 1 + net_per_cycle: -131 +- item: Cowhide + id: 1739 + buy: 137 + sell: 137 + limit: 13000 + lowalch_price: 1 + value: 2 + net_per_cycle: -136 +- item: Gold amulet + id: 1692 + buy: 141 + sell: 144 + limit: 18000 + lowalch_price: 140 + value: 350 + net_per_cycle: -1 +- item: Plank + id: 960 + buy: 141 + sell: 146 + limit: 13000 + lowalch_price: 1 + value: 1 + net_per_cycle: -140 +- item: Unfired bowl + id: 1791 + buy: 141 + sell: 141 + limit: 10000 + lowalch_price: 1 + value: 2 + net_per_cycle: -140 +- item: Nature rune + id: 561 + buy: 145 + sell: 145 + limit: 18000 + lowalch_price: 72 + value: 180 + net_per_cycle: -73 +- item: Gold necklace + id: 1654 + buy: 148 + sell: 156 + limit: 18000 + lowalch_price: 180 + value: 450 + net_per_cycle: 32 +- item: Antipoison(1) + id: 179 + buy: 150 + sell: 173 + limit: 2000 + lowalch_price: 57 + value: 144 + net_per_cycle: -93 +- item: Coal + id: 453 + buy: 150 + sell: 153 + limit: 13000 + lowalch_price: 18 + value: 45 + net_per_cycle: -132 +- item: Dust + id: 30328 + buy: 150 + sell: 2499 + limit: 250 + lowalch_price: 1 + value: 2 + net_per_cycle: -149 +- item: Yellow dye + id: 1765 + buy: 151 + sell: 332 + limit: 150 + lowalch_price: 2 + value: 5 + net_per_cycle: -149 +- item: Gold amulet (u) + id: 1673 + buy: 152 + sell: 160 + limit: 18000 + lowalch_price: 140 + value: 350 + net_per_cycle: -12 +- item: Iron bar + id: 2351 + buy: 153 + sell: 157 + limit: 10000 + lowalch_price: 11 + value: 28 + net_per_cycle: -142 +- item: Air talisman + id: 1438 + buy: 154 + sell: 165 + limit: 11000 + lowalch_price: 1 + value: 4 + net_per_cycle: -153 +- item: Chocolate cake + id: 1897 + buy: 155 + sell: 160 + limit: 6000 + lowalch_price: 28 + value: 70 + net_per_cycle: -127 +- item: Gold ore + id: 444 + buy: 155 + sell: 155 + limit: 30000 + lowalch_price: 60 + value: 150 + net_per_cycle: -95 +- item: Cake + id: 1891 + buy: 158 + sell: 161 + limit: 6000 + lowalch_price: 20 + value: 50 + net_per_cycle: -138 +- item: Gold ring + id: 1635 + buy: 160 + sell: 162 + limit: 18000 + lowalch_price: 140 + value: 350 + net_per_cycle: -20 +- item: Mithril ore + id: 447 + buy: 161 + sell: 158 + limit: 13000 + lowalch_price: 64 + value: 162 + net_per_cycle: -97 +- item: Charcoal + id: 973 + buy: 162 + sell: 169 + limit: 13000 + lowalch_price: 18 + value: 45 + net_per_cycle: -144 +- item: Hard leather + id: 1743 + buy: 170 + sell: 182 + limit: 13000 + lowalch_price: 1 + value: 1 + net_per_cycle: -169 +- item: Leather + id: 1741 + buy: 171 + sell: 193 + limit: 13000 + lowalch_price: 1 + value: 1 + net_per_cycle: -170 +- item: Asgarnian ale + id: 1905 + buy: 173 + sell: 245 + limit: 2000 + lowalch_price: 1 + value: 2 + net_per_cycle: -172 +- item: Red spiders' eggs + id: 223 + buy: 175 + sell: 180 + limit: 13000 + lowalch_price: 2 + value: 7 + net_per_cycle: -173 +- item: Maple shortbow + id: 853 + buy: 176 + sell: 160 + limit: 18000 + lowalch_price: 160 + value: 400 + net_per_cycle: -16 +- item: Death rune + id: 560 + buy: 190 + sell: 195 + limit: 25000 + lowalch_price: 72 + value: 180 + net_per_cycle: -118 +- item: Energy potion(4) + id: 3008 + buy: 191 + sell: 194 + limit: 2000 + lowalch_price: 58 + value: 146 + net_per_cycle: -133 +- item: Team-40 cape + id: 4393 + buy: 191 + sell: 48 + limit: 150 + lowalch_price: 20 + value: 50 + net_per_cycle: -171 +- item: Swordfish + id: 373 + buy: 199 + sell: 202 + limit: 6000 + lowalch_price: 32 + value: 80 + net_per_cycle: -167 +- item: Team-41 cape + id: 4395 + buy: 200 + sell: 741 + limit: 150 + lowalch_price: 20 + value: 50 + net_per_cycle: -180 +- item: Raw swordfish + id: 371 + buy: 206 + sell: 215 + limit: 15000 + lowalch_price: 32 + value: 80 + net_per_cycle: -174 +- item: Green dye + id: 1771 + buy: 209 + sell: 300 + limit: 150 + lowalch_price: 2 + value: 5 + net_per_cycle: -207 +- item: Monkey bones + id: 3183 + buy: 217 + sell: 331 + limit: 3000 + lowalch_price: 1 + value: 1 + net_per_cycle: -216 +- item: Raw t-bone steak + id: 33106 + buy: 217 + sell: 229 + limit: 13000 + lowalch_price: 4 + value: 10 + net_per_cycle: -213 +- item: Limpwurt root + id: 225 + buy: 220 + sell: 223 + limit: 13000 + lowalch_price: 2 + value: 7 + net_per_cycle: -218 +- item: Brown toy horsey + id: 2520 + buy: 222 + sell: 528 + limit: 150 + lowalch_price: 40 + value: 100 + net_per_cycle: -182 +- item: Team-29 cape + id: 4371 + buy: 224 + sell: 99 + limit: 150 + lowalch_price: 20 + value: 50 + net_per_cycle: -204 +- item: Big bones + id: 532 + buy: 232 + sell: 244 + limit: 3000 + lowalch_price: 1 + value: 1 + net_per_cycle: -231 +- item: Uncut sapphire + id: 1623 + buy: 234 + sell: 234 + limit: 10000 + lowalch_price: 10 + value: 25 + net_per_cycle: -224 +- item: Sapphire + id: 1607 + buy: 239 + sell: 257 + limit: 13000 + lowalch_price: 100 + value: 250 + net_per_cycle: -139 +- item: Orange cape + id: 1031 + buy: 251 + sell: 528 + limit: 150 + lowalch_price: 12 + value: 32 + net_per_cycle: -239 +- item: Grey gloves + id: 2902 + buy: 254 + sell: 1365 + limit: 150 + lowalch_price: 200 + value: 500 + net_per_cycle: -54 +- item: Orange dye + id: 1769 + buy: 260 + sell: 261 + limit: 150 + lowalch_price: 2 + value: 5 + net_per_cycle: -258 +- item: Pie shell + id: 2315 + buy: 260 + sell: 260 + limit: 13000 + lowalch_price: 1 + value: 4 + net_per_cycle: -259 +- item: Uncooked apple pie + id: 2317 + buy: 264 + sell: 350 + limit: 13000 + lowalch_price: 6 + value: 16 + net_per_cycle: -258 +- item: Water talisman + id: 1444 + buy: 264 + sell: 270 + limit: 11000 + lowalch_price: 1 + value: 4 + net_per_cycle: -263 +- item: Maple longbow + id: 851 + buy: 266 + sell: 271 + limit: 18000 + lowalch_price: 256 + value: 640 + net_per_cycle: -10 +- item: Antipoison(2) + id: 177 + buy: 268 + sell: 500 + limit: 2000 + lowalch_price: 86 + value: 216 + net_per_cycle: -182 +- item: Fur + id: 6814 + buy: 278 + sell: 340 + limit: 18000 + lowalch_price: 4 + value: 10 + net_per_cycle: -274 +- item: Meat pizza + id: 2293 + buy: 278 + sell: 293 + limit: 10000 + lowalch_price: 20 + value: 50 + net_per_cycle: -258 +- item: Team-30 cape + id: 4373 + buy: 295 + sell: 350 + limit: 150 + lowalch_price: 20 + value: 50 + net_per_cycle: -275 +- item: Team-25 cape + id: 4363 + buy: 300 + sell: 600 + limit: 150 + lowalch_price: 20 + value: 50 + net_per_cycle: -280 +- item: Brass necklace + id: 1009 + buy: 302 + sell: 302 + limit: 150 + lowalch_price: 12 + value: 30 + net_per_cycle: -290 +- item: Team-49 cape + id: 4411 + buy: 309 + sell: 1 + limit: 150 + lowalch_price: 20 + value: 50 + net_per_cycle: -289 +- item: Team-26 cape + id: 4365 + buy: 310 + sell: 54 + limit: 150 + lowalch_price: 20 + value: 50 + net_per_cycle: -290 +- item: Antipoison(3) + id: 175 + buy: 315 + sell: 461 + limit: 2000 + lowalch_price: 115 + value: 288 + net_per_cycle: -200 +- item: Grey toy horsey + id: 2526 + buy: 318 + sell: 140 + limit: 150 + lowalch_price: 40 + value: 100 + net_per_cycle: -278 +- item: White toy horsey + id: 2522 + buy: 325 + sell: 3400 + limit: 150 + lowalch_price: 40 + value: 100 + net_per_cycle: -285 +- item: Uncooked meat pie + id: 2319 + buy: 334 + sell: 349 + limit: 13000 + lowalch_price: 3 + value: 8 + net_per_cycle: -331 +- item: Chef's hat + id: 1949 + buy: 353 + sell: 339 + limit: 150 + lowalch_price: 1 + value: 2 + net_per_cycle: -352 +- item: Triangle sandwich + id: 6962 + buy: 355 + sell: 351 + limit: 6000 + lowalch_price: 10 + value: 25 + net_per_cycle: -345 +- item: Red dye + id: 1763 + buy: 367 + sell: 500 + limit: 150 + lowalch_price: 2 + value: 5 + net_per_cycle: -365 +- item: Sapphire amulet (u) + id: 1675 + buy: 375 + sell: 403 + limit: 10000 + lowalch_price: 360 + value: 900 + net_per_cycle: -15 +- item: Uncooked berry pie + id: 2321 + buy: 386 + sell: 420 + limit: 13000 + lowalch_price: 2 + value: 6 + net_per_cycle: -384 +- item: Plain pizza + id: 2289 + buy: 393 + sell: 439 + limit: 10000 + lowalch_price: 16 + value: 40 + net_per_cycle: -377 +- item: Team-33 cape + id: 4379 + buy: 397 + sell: 397 + limit: 150 + lowalch_price: 20 + value: 50 + net_per_cycle: -377 +- item: Team-19 cape + id: 4351 + buy: 402 + sell: 446 + limit: 150 + lowalch_price: 20 + value: 50 + net_per_cycle: -382 +- item: Emerald + id: 1605 + buy: 407 + sell: 420 + limit: 13000 + lowalch_price: 200 + value: 500 + net_per_cycle: -207 +- item: Sapphire ring + id: 1637 + buy: 409 + sell: 423 + limit: 10000 + lowalch_price: 360 + value: 900 + net_per_cycle: -49 +- item: Black cape + id: 1019 + buy: 413 + sell: 435 + limit: 150 + lowalch_price: 2 + value: 7 + net_per_cycle: -411 +- item: Burnt bones + id: 528 + buy: 418 + sell: 574 + limit: 3000 + lowalch_price: 1 + value: 1 + net_per_cycle: -417 +- item: Antipoison(4) + id: 2446 + buy: 440 + sell: 482 + limit: 2000 + lowalch_price: 144 + value: 360 + net_per_cycle: -296 +- item: Sapphire necklace + id: 1656 + buy: 442 + sell: 449 + limit: 18000 + lowalch_price: 420 + value: 1050 + net_per_cycle: -22 +- item: Team-8 cape + id: 4329 + buy: 448 + sell: 423 + limit: 150 + lowalch_price: 20 + value: 50 + net_per_cycle: -428 +- item: Team-42 cape + id: 4397 + buy: 463 + sell: 398 + limit: 150 + lowalch_price: 20 + value: 50 + net_per_cycle: -443 +- item: Uncut emerald + id: 1621 + buy: 470 + sell: 465 + limit: 10000 + lowalch_price: 20 + value: 50 + net_per_cycle: -450 +- item: Sapphire amulet + id: 1694 + buy: 477 + sell: 519 + limit: 10000 + lowalch_price: 360 + value: 900 + net_per_cycle: -117 +- item: Purple dye + id: 1773 + buy: 479 + sell: 693 + limit: 150 + lowalch_price: 2 + value: 5 + net_per_cycle: -477 +- item: Blue dye + id: 1767 + buy: 500 + sell: 596 + limit: 150 + lowalch_price: 2 + value: 5 + net_per_cycle: -498 +- item: Teal gloves + id: 2932 + buy: 500 + sell: 508 + limit: 150 + lowalch_price: 200 + value: 500 + net_per_cycle: -300 +- item: Purple cape + id: 1029 + buy: 530 + sell: 576 + limit: 150 + lowalch_price: 12 + value: 32 + net_per_cycle: -518 +- item: Anchovy pizza + id: 2297 + buy: 541 + sell: 553 + limit: 10000 + lowalch_price: 24 + value: 60 + net_per_cycle: -517 +- item: Team-22 cape + id: 4357 + buy: 568 + sell: 99000 + limit: 150 + lowalch_price: 20 + value: 50 + net_per_cycle: -548 +- item: Steel bar + id: 2353 + buy: 570 + sell: 570 + limit: 10000 + lowalch_price: 40 + value: 100 + net_per_cycle: -530 +- item: Adamantite ore + id: 449 + buy: 573 + sell: 585 + limit: 4500 + lowalch_price: 160 + value: 400 + net_per_cycle: -413 +- item: Team-32 cape + id: 4377 + buy: 580 + sell: 580 + limit: 150 + lowalch_price: 20 + value: 50 + net_per_cycle: -560 +- item: Emerald amulet (u) + id: 1677 + buy: 591 + sell: 602 + limit: 10000 + lowalch_price: 510 + value: 1275 + net_per_cycle: -81 +- item: Team-38 cape + id: 4389 + buy: 603 + sell: 4000 + limit: 150 + lowalch_price: 20 + value: 50 + net_per_cycle: -583 +- item: Team-12 cape + id: 4337 + buy: 607 + sell: 2500 + limit: 150 + lowalch_price: 20 + value: 50 + net_per_cycle: -587 +- item: Earth talisman + id: 1440 + buy: 608 + sell: 635 + limit: 11000 + lowalch_price: 1 + value: 4 + net_per_cycle: -607 +- item: Bone fragments + id: 25139 + buy: 627 + sell: 629 + limit: 500 + lowalch_price: 1 + value: 1 + net_per_cycle: -626 +- item: Emerald ring + id: 1639 + buy: 640 + sell: 649 + limit: 10000 + lowalch_price: 510 + value: 1275 + net_per_cycle: -130 +- item: Team-17 cape + id: 4347 + buy: 644 + sell: 1114 + limit: 150 + lowalch_price: 20 + value: 50 + net_per_cycle: -624 +- item: Emerald necklace + id: 1658 + buy: 655 + sell: 675 + limit: 18000 + lowalch_price: 570 + value: 1425 + net_per_cycle: -85 +- item: Team-31 cape + id: 4375 + buy: 671 + sell: 50 + limit: 150 + lowalch_price: 20 + value: 50 + net_per_cycle: -651 +- item: Green cape + id: 1027 + buy: 684 + sell: 652 + limit: 150 + lowalch_price: 12 + value: 32 + net_per_cycle: -672 +- item: Emerald amulet + id: 1696 + buy: 690 + sell: 719 + limit: 10000 + lowalch_price: 510 + value: 1275 + net_per_cycle: -180 +- item: Team-18 cape + id: 4349 + buy: 698 + sell: 542 + limit: 150 + lowalch_price: 20 + value: 50 + net_per_cycle: -678 +- item: Team-27 cape + id: 4367 + buy: 738 + sell: 545 + limit: 150 + lowalch_price: 20 + value: 50 + net_per_cycle: -718 +- item: Castle wars bracelet(3) + id: 11079 + buy: 750 + sell: 779 + limit: 10000 + lowalch_price: 670 + value: 1675 + net_per_cycle: -80 +- item: Purple gloves + id: 2942 + buy: 750 + sell: 900 + limit: 150 + lowalch_price: 200 + value: 500 + net_per_cycle: -550 +- item: Team-1 cape + id: 4315 + buy: 755 + sell: 792 + limit: 150 + lowalch_price: 20 + value: 50 + net_per_cycle: -735 +- item: Ruby + id: 1603 + buy: 819 + sell: 840 + limit: 13000 + lowalch_price: 400 + value: 1000 + net_per_cycle: -419 +- item: Yellow gloves + id: 2922 + buy: 869 + sell: 748 + limit: 150 + lowalch_price: 200 + value: 500 + net_per_cycle: -669 +- item: Team-11 cape + id: 4335 + buy: 892 + sell: 1445 + limit: 150 + lowalch_price: 20 + value: 50 + net_per_cycle: -872 +- item: Yellow bead + id: 1472 + buy: 900 + sell: 1050 + limit: 11000 + lowalch_price: 1 + value: 4 + net_per_cycle: -899 +- item: Mithril bar + id: 2359 + buy: 956 + sell: 973 + limit: 10000 + lowalch_price: 120 + value: 300 + net_per_cycle: -836 +- item: Purple boots + id: 2934 + buy: 966 + sell: 1333 + limit: 150 + lowalch_price: 200 + value: 500 + net_per_cycle: -766 +- item: Teal boots + id: 2924 + buy: 980 + sell: 1289 + limit: 150 + lowalch_price: 200 + value: 500 + net_per_cycle: -780 +- item: Ring of forging + id: 2568 + buy: 1000 + sell: 1032 + limit: 10000 + lowalch_price: 810 + value: 2025 + net_per_cycle: -190 +- item: Wine of Zamorak + id: 245 + buy: 1009 + sell: 1037 + limit: 11000 + lowalch_price: 1 + value: 1 + net_per_cycle: -1008 +- item: Team-5 cape + id: 4323 + buy: 1032 + sell: 44000 + limit: 150 + lowalch_price: 20 + value: 50 + net_per_cycle: -1012 +- item: Red gloves + id: 2912 + buy: 1039 + sell: 1039 + limit: 150 + lowalch_price: 200 + value: 500 + net_per_cycle: -839 +- item: Ruby ring + id: 1641 + buy: 1040 + sell: 1052 + limit: 10000 + lowalch_price: 810 + value: 2025 + net_per_cycle: -230 +- item: Red boots + id: 2904 + buy: 1063 + sell: 1321 + limit: 150 + lowalch_price: 200 + value: 500 + net_per_cycle: -863 +- item: Uncut ruby + id: 1619 + buy: 1074 + sell: 1083 + limit: 10000 + lowalch_price: 40 + value: 100 + net_per_cycle: -1034 +- item: Ruby amulet (u) + id: 1679 + buy: 1098 + sell: 1126 + limit: 10000 + lowalch_price: 810 + value: 2025 + net_per_cycle: -288 +- item: Team-48 cape + id: 4409 + buy: 1100 + sell: 1500 + limit: 150 + lowalch_price: 20 + value: 50 + net_per_cycle: -1080 +- item: Team-21 cape + id: 4355 + buy: 1108 + sell: 2300 + limit: 150 + lowalch_price: 20 + value: 50 + net_per_cycle: -1088 +- item: Black bead + id: 1474 + buy: 1111 + sell: 1150 + limit: 11000 + lowalch_price: 1 + value: 4 + net_per_cycle: -1110 +- item: Ruby amulet + id: 1698 + buy: 1111 + sell: 1140 + limit: 10000 + lowalch_price: 810 + value: 2025 + net_per_cycle: -301 +- item: Ruby necklace + id: 1660 + buy: 1127 + sell: 1127 + limit: 18000 + lowalch_price: 870 + value: 2175 + net_per_cycle: -257 +- item: Grey boots + id: 2894 + buy: 1129 + sell: 1865 + limit: 150 + lowalch_price: 200 + value: 500 + net_per_cycle: -929 +- item: White bead + id: 1476 + buy: 1180 + sell: 1180 + limit: 11000 + lowalch_price: 1 + value: 4 + net_per_cycle: -1179 +- item: Mind talisman + id: 1448 + buy: 1189 + sell: 1348 + limit: 11000 + lowalch_price: 1 + value: 4 + net_per_cycle: -1188 +- item: Team-35 cape + id: 4383 + buy: 1210 + sell: 2108 + limit: 150 + lowalch_price: 20 + value: 50 + net_per_cycle: -1190 +- item: Team-4 cape + id: 4321 + buy: 1359 + sell: 951 + limit: 150 + lowalch_price: 20 + value: 50 + net_per_cycle: -1339 +- item: Body talisman + id: 1446 + buy: 1654 + sell: 1660 + limit: 11000 + lowalch_price: 1 + value: 4 + net_per_cycle: -1653 +- item: Diamond + id: 1601 + buy: 1683 + sell: 1699 + limit: 11000 + lowalch_price: 800 + value: 2000 + net_per_cycle: -883 +- item: Adamantite bar + id: 2361 + buy: 1835 + sell: 1864 + limit: 10000 + lowalch_price: 256 + value: 640 + net_per_cycle: -1579 +- item: Red bead + id: 1470 + buy: 1905 + sell: 1905 + limit: 11000 + lowalch_price: 1 + value: 4 + net_per_cycle: -1904 +- item: Diamond amulet (u) + id: 1681 + buy: 1913 + sell: 1915 + limit: 10000 + lowalch_price: 1410 + value: 3525 + net_per_cycle: -503 +- item: Diamond ring + id: 1643 + buy: 1938 + sell: 1957 + limit: 10000 + lowalch_price: 1410 + value: 3525 + net_per_cycle: -528 +- item: Diamond necklace + id: 1662 + buy: 1990 + sell: 1996 + limit: 18000 + lowalch_price: 1470 + value: 3675 + net_per_cycle: -520 +- item: Team-37 cape + id: 4387 + buy: 2000 + sell: 900 + limit: 150 + lowalch_price: 20 + value: 50 + net_per_cycle: -1980 +- item: Diamond amulet + id: 1700 + buy: 2090 + sell: 2124 + limit: 10000 + lowalch_price: 1410 + value: 3525 + net_per_cycle: -680 +- item: Fire talisman + id: 1442 + buy: 2311 + sell: 2330 + limit: 11000 + lowalch_price: 1 + value: 4 + net_per_cycle: -2310 +- item: Uncut diamond + id: 1617 + buy: 2450 + sell: 2483 + limit: 10000 + lowalch_price: 80 + value: 200 + net_per_cycle: -2370 +- item: Yellow boots + id: 2914 + buy: 2471 + sell: 4800 + limit: 150 + lowalch_price: 200 + value: 500 + net_per_cycle: -2271 +- item: Team-14 cape + id: 4341 + buy: 3074 + sell: 546 + limit: 150 + lowalch_price: 20 + value: 50 + net_per_cycle: -3054 +- item: Muddy key + id: 991 + buy: 6234 + sell: 6616 + limit: 11000 + lowalch_price: 32 + value: 80 + net_per_cycle: -6202 +- item: Runite ore + id: 451 + buy: 10265 + sell: 10265 + limit: 4500 + lowalch_price: 1280 + value: 3200 + net_per_cycle: -8985 +- item: Runite bar + id: 2363 + buy: 12230 + sell: 12236 + limit: 10000 + lowalch_price: 2000 + value: 5000 + net_per_cycle: -10230 +- item: Team-24 cape + id: 4361 + buy: 60000 + sell: 632 + limit: 150 + lowalch_price: 20 + value: 50 + net_per_cycle: -59980 diff --git a/codex/economy/flip-candidates.yaml b/codex/economy/flip-candidates.yaml new file mode 100644 index 0000000..d25e87c --- /dev/null +++ b/codex/economy/flip-candidates.yaml @@ -0,0 +1,626 @@ +id: flip-candidates +title: GE Flipping (margin trading) +domain: economy +tags: [flipping, merchanting, ge, money-making, hybrid] +status: verified +version: 6 # 6: bounce-fix 7 2026-08-29 (governor comment 10471) — 'closed' is no longer a sink: after a completed sell's proceeds are collected and the ledger is reconciled + ARCHIVED, the physical GE slot is released to free via the explicit archival transition `closed -> free` (a deliberately closed cancelled sell releases the same way), and the committed two-rotation regression proves one slot completes buy/collect/sell/collect, archives the first ledger, and opens a SECOND buy in the same slot without losing, duplicating, or mutating the archived record. Previous (v5, bounce-fix 6): the 4h buy-limit reset NEVER cancels an offer (offer lifetime = independent quote_age_policy: stale edge or strategy reallocation only); explicit buy-filled-awaiting-collect / sell-filled-awaiting-collect states so completed offers must pass through the GE collection box (ge_collect) before reuse or closing; SIDE-AWARE per-slot ledgers (buy: quoted units = filled + still_open + cancelled_unfilled, acquired items = filled ONLY, cash = spent + still_committed + refunded; sell: quoted = sold + still_open + returned_unsold) with committed negative fixtures for phantom refunded buy units, a full buy sold before collection, and a full sell closed before proceeds collection. Capped/floored tax model + its 4 selftest fixtures and the asynchronous scheduler behavior preserved (owner decisions 2026-08-28). +updated: "2026-08-28" # ISO dates must stay quoted — YAML 1.1 parses bare dates to datetime.date + +summary: >- + Buy-and-sell Grand Exchange margin flipping: place resting buy offers at + the low edge of an item's price band and resting sell offers at the high + edge, harvesting the spread on high-volume items. There are no skill or + quest requirements — capital breadth and slot discipline are the whole + game. Members accounts run 8 GE slots over the full candidate set; F2P + accounts get 3 slots and F2P-tradeable rows only (separate F2P path in + profit_drivers). Model (governor 10323 + 10390, 2026-08-28): every GE + slot is an INDEPENDENT asynchronous sub-state machine (free / buy-open / + buy-filled-awaiting-collect / buy-cancelled-awaiting-collect / + ready-to-sell / sell-open / sell-filled-awaiting-collect / + sell-cancelled-awaiting-collect / ready-to-requote / closed) driven by a + global scheduler pass that acts only on dispatchable slots and never + closes or advances unrelated ones; completed offers (full buys and full + sells) land in the GE COLLECTION BOX and must pass through ge_collect + + ledger reconciliation before assets are reused or the slot closes; + 'closed' is NOT a sink — the physical GE slot is released to free via + the archival transition `closed -> free` after the completed cycle's + ledger is reconciled + archived, so every slot runs flip after flip + through rotations and breaks (two-rotation reuse regression); the + 4h buy-limit ledger (limit_used / first_fill_ts / limit_resets_at / + remaining) is DISTINCT from offer expiry — a limit-window reset NEVER + cancels an offer and cancellation NEVER renews the limit; ge_cancel + moves filled items, refunded CASH, unsold units and proceeds to the box, + and every snapshot satisfies SIDE-AWARE per-slot conservation (buy: + quoted units == filled + still_open + cancelled_unfilled with acquired + items == filled only, cash == spent + still_committed + refunded; sell: + quoted == sold + still_open + returned_unsold); + economy.ceiling_gp_per_hour is the TYPED economic ceiling and + gp_per_hour_low/high are documented realized scenario bounds. Pinned + worked example + committed validator fixtures prove the model. + +accounts: + membership: hybrid # members: 8 slots/full set; F2P: 3 slots/F2P rows (see economy) + min_level: 3 # post-Tutorial Island baseline; no skill requirement + requires_quest: [] + requires_other: + - "Post-Tutorial Island account with Grand Exchange access (no skill or quest requirement)" + - "Members account: 8 GE slots, full candidate set (the modeled path below). F2P account: 3 GE slots and F2P-tradeable rows only — run the F2P path in profit_drivers." + - "Minimum starting capital: per slot = buy edge x 4h limit (arrowtips 2.23M, iron bar 1.45M, cosmic 2.196M, silver ore 0.949M, swordfish 3.09M, lobster 1.59M, magic logs 9.264M, mud 1.728M at the 2026-08-27 snapshot) — full 8-slot members coverage ~22.5M; the 3-slot F2P path needs ~5.5M (iron 1.45M + cosmic 2.196M + silver 0.949M). Capital below one slot's requirement takes that slot off the table." + +best_practice: + equipment: + - item: cash stack (flip capital) + why: "Gross margin per item is a few gp, so profit scales with quantity and the 4h + buy-limit window caps per-item volume — capital breadth (more candidates/slots + covered) matters more than any gear. Bulk flipping at scale needs ~20M+ cash." + source: "OSRS wiki — Merchanting (Bulk quantity flipping)" + techniques: + - name: flip the spread, not the trend + why: "Flipping profit is the buy/sell spread between pending offers (the impatience + premium), NOT price movement; it is safer when the item has high trade volume." + source: "OSRS wiki — Merchanting (Flipping)" + - name: prefer dynamic-price items, avoid static-price items + why: "Static-price items have no spread to harvest; only dynamic-price items carry + an exploitable margin." + source: "OSRS wiki — Merchanting (flipping guidance: 'strongly recommended to not + flip static-price items')" + - name: quote at the band edges, never instant-buy/sell + why: "Instant matching pays the standing offer's price (the market's impatience + price); a resting offer fills when a matching offer appears at or better than your + price. The 2% convenience fee makes sub-2%-spread staples unprofitable at band + edges outright, so the edges are where the margin lives." + source: "OSRS wiki — Merchanting (How the Grand Exchange works); OSRS wiki — Grand Exchange + (2% tax since 2025-05-29, capped 5M/item)" + - name: pace quotes inside the 4h buy-limit window, watch connected limits + why: "The buying rate is restricted to a set quantity every 4 hours per player, and + some items share a connected limit with related items — treat those as one bucket + when filling slots. The window starts when the FIRST unit of the window is bought + (first_fill_ts) and resets 4h later. The reset ONLY restores buying capacity — + it never cancels, expires, or otherwise affects a resting offer — and cancelling + an offer does NOT renew or restart the window." + source: "OSRS wiki — Grand Exchange (Buy limits; Grand Exchange/Buying limits)" + - name: run the path that matches the account's slot set + why: "Members get eight GE slots and may trade any row; F2P accounts get three slots + and may only purchase free-to-play rows. The modeled economics below assume the + members set — a hybrid account must swap to the 3-slot F2P path (Iron bar / + Cosmic rune / Silver ore) instead of chasing members-only rows it cannot buy." + source: "OSRS wiki — Grand Exchange (Members get eight Grand Exchange slots ... free players are limited to three. Additionally, free players may only purchase members' items)" + - name: treat each slot as its own asynchronous machine; the scheduler never touches resting slots + why: "A slot's offer fills on its own timeline (partial fills keep resting). The + global scheduler pass acts only on dispatchable slots (free -> quote, + *-awaiting-collect -> ge_collect, ready-to-sell -> sell, ready-to-requote -> + requote, closed -> free archival release) and leaves resting buy-open / + sell-open slots untouched — one slot's cancel or close never advances an + unrelated slot. A FULLY FILLED offer never sells or closes directly: it + first enters an explicit buy-/sell-filled-awaiting-collect state because + its assets sit in the GE collection box until ge_collect (finding 2, + governor 10390). A 'closed' slot is only ever transient: the completed + cycle's ledger is archived at close and the pass releases the physical + slot to free (closed -> free), so the same slot runs the next flip — + never one flip and done (governor 10471)." + source: "governor review comments 10323 + 10390 + 10471 (2026-08-28/29); OSRS wiki — Grand Exchange / Bank (collection box); docs/action-grammar.md" + - name: keep a per-slot offer ledger; ge_cancel -> collection box -> ge_collect -> reconcile, never strand units + why: "Every slot tracks its side-aware ledger: a BUY quote partitions units into + filled + still_open + cancelled_unfilled with acquired items = filled ONLY (the + unfilled/cancelled quantity is CASH: spent / still_committed / refunded), and a + SELL quote partitions sold + still_open + returned_unsold. ge_cancel moves + filled items, refunded CASH, unsold units and sale proceeds to the GE COLLECTION + BOX (never directly to inventory); a separate ge_collect withdraws them and the + ledger is reconciled before the slot is reused. The ONLY cancel triggers are a + stale scanned edge (margin no longer positive) or an explicit strategy + reallocation — never the 4h buy-limit reset, which merely restores capacity." + source: "OSRS wiki — Grand Exchange (offer mechanics; collection); Bank (collection interface); governor review comment 10390 findings 1-4; docs/action-grammar.md (ge_cancel / ge_collect)" + +economy: + # Model (documented, reproducible from the snapshot): + # Members path (8 GE slots — the modeled economics below): + # per slot: buy `limit` units at the low edge, sell at the high edge. + # tax_pu = min(floor(0.02 * sell), 5_000_000) # 2% convenience fee, + # # rounded DOWN to whole coins per item, + # # capped 5M/item (owner decision + # # 2026-08-28; cap never hit here) + # net_pu = sell - tax_pu - buy # integer per-item net (floored tax) + # 4h/slot = net_pu * limit # buy-limit window caps per-4h volume + # F2P path (3 GE slots, F2P-tradeable rows only): same arithmetic, top-3 + # F2P rows — see profit_drivers for the table and rate. + # Snapshots 2026-08-27: prices.runescape.wiki mapping + latest; limits and + # members flags cross-checked against the wiki item pages (all agree). Top 8 + # by 4h/slot below in profit_drivers. + # + # Lifetime qualification (governor 10323 finding 5, 2026-08-28): + # ceiling_gp_per_hour (149,500) is the TYPED ECONOMIC CEILING: ideal full + # fills of every slot, zero overhead — never presented as an achieved + # runtime rate. gp_per_hour_low/high are DOCUMENTED REALIZED SCENARIO + # BOUNDS (not uncited multipliers), each with explicit arithmetic in + # profit_drivers: + # high 138,000 = 149,500 x 37/40 — 'resting-fill' scenario: all 8 slots + # full-fill at their quoted edges; each 40-min rotation is 37 min of + # resting waits + 3 min of scan/place/collect actions. + # low 74,750 = 149,500 x 0.50 — 'requote-heavy' scenario: every 4h + # window's volume completes only after one stale-edge ge_cancel + + # requote cycle (the no-fill remainder is refunded to the collection + # box; the slot re-quotes when the margin clears again or capacity + # allows), halving the window's filled volume. + # Bounds nest: 0 <= 74,750 <= 138,000 <= 149,500 (validator-enforced). + item: Rune arrowtips # anchor: top candidate by modeled 4h/slot (members) + item_id: 44 + cost_per_cycle: 223 # buy @ low edge per unit (snapshot 2026-08-27) + income_per_cycle: 244 # sell @ high edge per unit, gross (snapshot 2026-08-27) + net_per_cycle: 17 # 244 - min(floor(244*0.02), 5M) - 223 = 244 - 4 - 223 (floored, capped tax) + ceiling_gp_per_hour: 149500 # TYPED economic ceiling: sum of the 8 slots' 4h/slot (= 598,000) / 4h — ideal full fills, zero overhead (see profit_drivers table) + gp_per_hour_low: 74750 # realized scenario bound 'requote-heavy' = 149,500 x 0.50 (documented in profit_drivers) + gp_per_hour_high: 138000 # realized scenario bound 'resting-fill' = 149,500 x 37/40 (documented in profit_drivers) + xp_per_hour: 0 # flipping awards no XP + supplies: + - item: Rune arrowtips # canonical wiki names per action-grammar conventions + qty_per_cycle: 1 + source: ge + ge_limit: 10000 # mapping + Module:Exchange agree + - item: Iron bar + qty_per_cycle: 1 + source: ge + ge_limit: 10000 + - item: Cosmic rune + qty_per_cycle: 1 + source: ge + ge_limit: 18000 + profit_drivers: + - "Spread per unit x trade volume; volume is the safety factor (high-volume items fill faster and drift less)" + - "Slot breadth: cover as many net-positive candidates as capital allows (members 8 slots / F2P 3 slots)" + - "Members path — snapshot 2026-08-27 top 8 by modeled 4h/slot (floored+capped 2% tax): Rune arrowtips 170,000 (M, buy 223 / sell 244 / limit 10000); Iron bar 90,000 (F2P, 145/157/10000); Cosmic rune 72,000 (F2P, 122/128/18000); Magic logs 72,000 (M, 772/793/12000); Silver ore 65,000 (F2P, 73/79/13000); Mud rune 54,000 (M, 96/101/18000); Raw swordfish 45,000 (F2P, 206/213/15000); Raw lobster 30,000 (F2P, 106/110/15000) — total 598,000 per 4h = 149,500 gp/h CEILING on 8 slots" + - "F2P path — 3 slots, F2P-tradeable rows only: Iron bar 90,000 + Cosmic rune 72,000 + Silver ore 65,000 = 227,000 per 4h = 56,750 gp/h ceiling (alternates: Raw swordfish 45,000, Raw lobster 30,000)" + - "Post-2%-fee reality: 11 of 21 classic staples are net-positive at band edges for members; sub-2%-spread items (law/chaos/blood runes, dragon bones, adamantite/mithril bars) need sub-low buys or above-high sells" + - "Realized bounds are DOCUMENTED SCENARIOS with explicit arithmetic (governor 10323 finding 5 — no uncited fill/duty multipliers): HIGH 'resting-fill' 138,000 gp/h is the ceiling scaled by the rotation duty of 37 resting minutes per 40-minute rotation (149,500 x 37/40 = 138,287.5 -> 138,000): scan/place/collect actions cost ~3 min per rotation at the 600s quote-check cadence; LOW 'requote-heavy' 74,750 gp/h assumes every 4h window's volume completes only after one stale-edge ge_cancel + requote cycle, so the no-fill remainder (refunded to the collection box and re-quoted when the margin clears or capacity allows) halves the filled volume (149,500 x 0.50). The ceiling 149,500 itself is an economic bound, never an achieved rate. Cancelling never renews the buy limit and the limit reset never cancels an offer (governor 10390 finding 1)." + +slot_state_machine: + sub_states: [free, buy-open, buy-filled-awaiting-collect, buy-cancelled-awaiting-collect, ready-to-sell, sell-open, sell-filled-awaiting-collect, sell-cancelled-awaiting-collect, ready-to-requote, closed] + slot_transitions: + free: [buy-open] + buy-open: [buy-filled-awaiting-collect, buy-cancelled-awaiting-collect] + buy-filled-awaiting-collect: [ready-to-sell, free] + buy-cancelled-awaiting-collect: [ready-to-sell, ready-to-requote, free] + ready-to-sell: [sell-open] + sell-open: [sell-filled-awaiting-collect, sell-cancelled-awaiting-collect] + sell-filled-awaiting-collect: [closed] + sell-cancelled-awaiting-collect: [ready-to-requote, closed] + ready-to-requote: [buy-open, sell-open, free] + closed: [free] # archival release — the physical slot is reusable for the next flip (governor 10471) + slot_transition_whens: + - "free -> buy-open: scheduler pass quotes a resting buy at the low edge (qty = min(remaining 4h limit, slot capital, capital available))" + - "buy-open -> buy-filled-awaiting-collect: offer FULLY filled (filled == quoted) — the units sit in the GE COLLECTION BOX until ge_collect; the slot may NOT sell them directly (governor 10390 finding 2)" + - "buy-open -> buy-cancelled-awaiting-collect: quote_age_policy abort — scanned edge stale (margin no longer positive) or explicit strategy reallocation; NEVER the 4h limit reset. ge_cancel moves filled items + refunded CASH (the unspent remainder) to the collection box; the buy limit is NOT renewed" + - "buy-filled-awaiting-collect -> ready-to-sell: ge_collect withdrawn the units; sell edge positive — dispatch sells" + - "buy-filled-awaiting-collect -> free: ge_collect withdrawn; margin gone or capital reallocated — bank the units, release the slot" + - "buy-cancelled-awaiting-collect -> ready-to-sell: ge_collect withdrawn the bought units; sell edge positive — sell the units in hand" + - "buy-cancelled-awaiting-collect -> ready-to-requote: ge_collect withdrawn; buy margin still positive AND remaining limit > 0 — requote at the fresh low edge" + - "buy-cancelled-awaiting-collect -> free: ge_collect withdrawn (full cash refund); slot released (margin gone or capital reallocated)" + - "ready-to-sell -> sell-open: scheduler pass quotes a resting sell at the high edge for the units in hand" + - "sell-open -> sell-filled-awaiting-collect: sell offer FULLY filled (sold == quoted) — the proceeds sit in the GE COLLECTION BOX until ge_collect; the slot may NOT close directly (governor 10390 finding 2)" + - "sell-open -> sell-cancelled-awaiting-collect: quote_age_policy abort — scanned edge stale or strategy reallocation; ge_cancel: unsold units + proceeds received so far move to the collection box (never to inventory)" + - "sell-filled-awaiting-collect -> closed: ge_collect withdrawn the proceeds; ledger reconciled and ARCHIVED — the archived cycle record is immutable; the physical slot is released at the next dispatch by the closed-to-free archival transition (governor 10471)" + - "sell-cancelled-awaiting-collect -> ready-to-requote: ge_collect withdrawn; requote the sell at the fresh high edge or the current band" + - "sell-cancelled-awaiting-collect -> closed: ge_collect withdrawn; ledger archived, deliberately closed (exit taken) — the physical slot releases to free exactly like a completed sell (governor 10471)" + - "closed -> free: ARCHIVAL RELEASE — the completed cycle's ledger stays archived (immutable, conserved), and the physical GE slot returns to free to place the next flip's buy (two-rotation reuse regression)" + - "ready-to-requote -> buy-open: requoted buy placed (buy_capacity_ok: limit_used + qty <= limit until limit_resets_at)" + - "ready-to-requote -> sell-open: requoted sell placed at the fresh high edge" + - "ready-to-requote -> free: slot abandoned and released" + scheduler_iteration: >- + One pass over every slot exactly once, at the 600 s quote-check cadence. + Only dispatchable slots act: free -> quote (ge_buy), buy-filled- + awaiting-collect / buy-cancelled-awaiting-collect / + sell-filled-awaiting-collect / sell-cancelled-awaiting-collect -> + ge_collect, ready-to-sell -> sell (ge_sell), ready-to-requote -> + requote, closed -> free (archival release: the completed cycle's + ledger was archived at close; the pass returns the physical slot to + free so the next flip can begin). Resting buy-open / sell-open slots + produce NO action and are never closed, advanced, re-quoted or touched + by the pass. A pass therefore advances only the slots whose sub-state + is dispatchable, a completed offer always passes through its + *-filled-awaiting-collect state (ge_collect) before its assets are + reused, and a closed slot is only ever transient — released to free + rather than left to strand after one flip (governor 10471). + quote_age_policy: >- + Offer lifetime is INDEPENDENT of the 4h buy-limit ledger. Resting + offers are re-checked at the 600 s cadence and kept resting while the + scanned margin stays positive — a profitable resting offer remains + open regardless of limit_resets_at (a limit-window reset only restores + buy capacity; it never cancels, expires, or implies cancelling an + offer). An offer is aborted ONLY on a stale band edge (scanned margin + no longer positive) or an explicit strategy reallocation (slot freed + for a better candidate / stop condition). No-fill alone never aborts an + offer whose edge still clears the margin. + persisted_state: >- + Per-slot record persisted between passes and across breaks: sub-state, + item, quoted qty, and SIDE-AWARE buckets — buy side: filled, still_open, + cancelled_unfilled, cash_quoted, spent, still_committed, refunded; + sell side: sold, still_open, returned_unsold, proceeds_in_box, + cash_collected; plus limit_used, first_fill_ts, limit_resets_at, + quote placed_at, last check_ts. The GE offers themselves persist + server-side; the record is the agent's copy of truth for the scheduler + and conservation checks. + limit_ledger: >- + Per item: limit_used (units bought so far in the current 4h window), + first_fill_ts (when the FIRST unit of the window was bought), + limit_resets_at = first_fill_ts + 4h, remaining = limit - limit_used. + The ledger controls ONLY the buying rate: it is DISTINCT from offer + expiry (offers rest independently of the window), cancelling never + renews or restarts it, and the reset never cancels an offer — a requote + before limit_resets_at may buy at most remaining (buy_capacity_ok: + limit_used + qty <= limit). A slot with limit_used == limit cannot + place a new or requoted buy until limit_resets_at; its resting offer + is unaffected. + conservation: >- + SIDE-AWARE per-slot invariants at every snapshot (governor 10390 + finding 4). BUY side: quoted units == filled + still_open + + cancelled_unfilled per item, and ACQUIRED ITEMS == filled ONLY — the + unfilled/cancelled quantity is a CASH refund, never refunded units; + cash_quoted == spent + still_committed + refunded. SELL side: quoted + units == sold + still_open + returned_unsold per item (a partial sell + legitimately spans sold + still_open). No negative or duplicated + quantities; impossible pairs (still_open + cancelled_unfilled on a + buy; still_open + returned_unsold on a sell) are violations. Phase + gates: a buy-filled-awaiting-collect slot has NOTHING sold yet (a full + buy cannot sell before ge_collect) and a sell-filled-awaiting-collect + slot holds its proceeds in the box; a 'closed' slot has no uncollected + proceeds or cancelled/returned units — it holds ONLY the archived + (immutable) cycle record, and the physical slot is released to free + via the closed -> free archival transition so the SAME slot can run + the next flip (governor 10471; two-rotation reuse regression: the + archived rotation-1 ledger survives rotation 2 bit-for-bit). + Committed validator fixtures — + including the negative fixtures (phantom refunded buy units; a full + buy sold before collection; a full sell closed before proceeds + collection; a 'closed' sink that strands the slot after one flip) — + prove the invariants and the scheduler dispatch. + break_resume: >- + Breaks are taken only when no slot is dispatchable: every slot is + resting (buy-open / sell-open) or free — never mid-collect, mid-place, + or in an *-awaiting-collect state. A closed slot is only ever + transient (archived and released to free by the pass), so a break + never catches the activity between archive and release; full state + (offers server-side + the persisted per-slot record) survives the + pause; resume by re-opening the GE, re-checking offers, re-running the + scheduler pass and continuing each slot from its persisted sub-state. + Slots and slots only: the 8-slot members / 3-slot F2P activity is + never downgraded to single-slot work. + +state_machine: + initial: idle + terminal: [complete, failed] + states: + idle: + goal: "be logged in at a bank with a GE booth, capital >= floor, every slot's ledger closed (sub-state free — a closed slot is transient and released to free by the pass — all live buckets zero)" + next: [scan, complete] + scan: + goal: "read the current high/low band for every candidate; compute net-positive edges with floored+capped tax (a3)" + next: [schedule, idle] + schedule: + goal: "run ONE scheduler pass over all 8 (members) or 3 (F2P) slots: dispatch free -> quote, *-awaiting-collect -> ge_collect, ready-to-sell -> sell, ready-to-requote -> requote; resting slots untouched (slot_state_machine.scheduler_iteration)" + next: [wait, collect] + wait: + goal: "hold resting offers; at the 600 s cadence re-check offers (a6) and re-run the scheduler pass (schedule); a slot leaves wait when it becomes dispatchable" + next: [schedule, collect] + collect: + goal: "ge_collect every awaiting-collect slot: withdraw filled items, refunds, unsold units and proceeds from the collection box; reconcile each slot's ledger (conservation invariants) and only then allow slot reuse (a9)" + next: [schedule, scan, complete, idle] + complete: + goal: "session GP target reached" + failed: + goal: "hard failure — stop, log ERROR sentinel, alert" + transitions: + - from: idle + when: "GE access available AND capital >= floor AND session target not reached" + to: scan + - from: idle + when: "capital below floor OR session target already reached" + to: complete + - from: scan + when: "at least one net-positive candidate at the scanned band edges (floored+capped tax)" + to: schedule + - from: scan + when: "no net-positive candidate on 3 consecutive scans (prices stale — stand down)" + to: idle + - from: schedule + when: "pass placed or requoted offers (quotes outstanding) — rest until the next cadence" + to: wait + - from: schedule + when: "pass marked slots awaiting collection (ge_cancel happened) — withdraw before further dispatch" + to: collect + - from: wait + when: "600 s cadence reached — re-check offers and re-run the scheduler pass" + to: schedule + - from: wait + when: "a slot filled and proceeds/results are ready to collect" + to: collect + - from: collect + when: "only resting slots remain (closed slots are released to free by the same pass); cadence continues" + to: wait + - from: collect + when: "session GP target reached" + to: complete + - from: collect + when: "rotation done and window open — start the next rotation" + to: scan + - from: collect + when: "capital fell below floor after reconciliation" + to: idle + +actions: + - id: a1 + step: reach-ge + verb: move_to + target: "bank with Grand Exchange booth" + params: + location: "Grand Exchange (Varrock) area bank" + method: [teleport, walk] + on_fail: retry + anti_ban: + delay: [400, 1600] + + - id: a2 + step: open-ge + verb: ge_open + target: grand-exchange + params: + booth: "GE counter or bank booth" + on_fail: retry + anti_ban: + delay: [800, 1800] + + - id: a3 + step: scan-bands + verb: ge_check_price + target: "current candidates" + params: + items: "top-N candidates from the entry table" + band: "high and low edge per item" + margin: "net-positive = sell - min(floor(0.02 x sell), 5,000,000) - buy > 0" + on_fail: skip + anti_ban: + delay: [1200, 2800] + + - id: a4 + step: place-buys + verb: ge_buy + target: grand-exchange + params: + item: "per dispatchable free slot (scheduler pass)" + qty: "min(remaining 4h limit, slot capacity, capital available) — buy_capacity_ok(qty + limit_used <= limit)" + price: "low edge (rounded down)" + max_price: "low edge — never instant" + ledger: "record quoted qty, cash_quoted, limit_used check; sub-state -> buy-open" + on_fail: skip + anti_ban: + delay: [900, 2700] + + - id: a5 + step: hold-fills + verb: sleep + params: + delay: [6000, 12000] + until: "ge_offers_done changes or a fill event or the 600s cadence (a resting offer keeps resting; the 4h limit reset plays no role in offer lifetime)" + on_fail: retry + anti_ban: + delay: [300, 900] + + - id: a6 + step: check-fills + verb: ge_offers_done + target: "all quoted slots" + params: + phase: "per-slot: buy-open (buy outstanding) | sell-open (sell outstanding)" + per_slot: "side-aware buckets per slot — buy: filled (units in the collection box) / still_open (resting offer) / cancelled_unfilled; sell: sold (proceeds in the box) / still_open / returned_unsold — partial fills keep the offer resting with filled units held in the box" + verdict: "full | partial | none | stale-edge # 'lapse' is NOT a verdict: the 4h window is a capacity clock, not an offer deadline" + on_fail: retry + + - id: a7 + step: place-sells + verb: ge_sell + target: grand-exchange + params: + item: "per ready-to-sell slot (units in hand AFTER ge_collect — a fully filled buy must first pass through buy-filled-awaiting-collect -> ge_collect)" + qty: "units in hand for that slot" + price: "high edge (rounded up)" + ledger: "record sold qty per slot; sub-state -> sell-open" + on_fail: skip + anti_ban: + delay: [900, 2700] + + - id: a8 + step: cancel-to-box + verb: ge_cancel + target: "slot offers aborted by quote_age_policy (stale edge / strategy reallocation)" + params: + offers: "slots whose scanned band edge no longer clears the margin (stale), or slots explicitly reallocated by the strategy — NEVER offers whose 4h limit-window merely reset (a reset restores buy capacity only; quote_age_policy)" + moved: "BUY cancel: filled items + refunded CASH (the unspent remainder) -> GE COLLECTION BOX; SELL cancel: unsold units + proceeds received so far -> GE COLLECTION BOX (never directly to inventory)" + limit: "the 4h buy limit is NOT renewed or restarted by the cancel (limit_used stays); the next reset does not cancel anything either" + sub_state: "buy-open -> buy-cancelled-awaiting-collect; sell-open -> sell-cancelled-awaiting-collect" + on_fail: retry + + - id: a9 + step: collect-and-reconcile + verb: ge_collect + target: grand-exchange + params: + withdraw: "from the GE collection box: bought (filled) items, refunded CASH, unsold units, sale proceeds — per slot" + reconcile: "SIDE-AWARE per-slot ledger conservation check BEFORE any slot reuse — buy: quoted == filled + still_open + cancelled_unfilled and cash_quoted == spent + still_committed + refunded; sell: quoted == sold + still_open + returned_unsold" + decide: "buy-filled/buy-cancelled -> ready-to-sell (sell edge positive) | ready-to-requote (buy margin positive and remaining limit > 0) | free (release); sell-filled/sell-cancelled -> closed | ready-to-requote" + archive: "at 'closed' the completed cycle's ledger is reconciled and ARCHIVED (immutable); the pass then releases the physical slot to free (closed -> free) so the SAME slot places the next flip's buy — the two-rotation reuse regression proves the archived record survives rotation 2 (governor 10471)" + log: "RESULT item, qty, net gp per slot (floored+capped tax)" + on_fail: alert + anti_ban: + delay: [600, 1400] + + - id: a10 + step: take-break + verb: take_break + target: "current location" + params: + duration: "[60, 240] seconds of idle AFK per break (matches run_control.break.for)" + safe: "only when every slot is resting (buy-open / sell-open) or free — resting offers persist server-side at their quoted edges and the per-slot record persists locally; a closed slot is transient (archived + released to free by the pass), so breaks never catch the activity between archive and release; never break mid-collect (coins in hand) or mid-place (offer half-configured)" + resume: "re-open GE (a2), re-check offer state (a6), re-run the scheduler pass (schedule), continue each slot from its persisted sub-state (a4/a7/a8/a9)" + on_fail: skip + +run_control: + iteration: "one scheduler pass over every slot at the 600 s cadence — dispatch free -> quote, *-awaiting-collect -> ge_collect, ready-to-sell -> sell, ready-to-requote -> requote; resting slots untouched (slot_state_machine.scheduler_iteration)" + max_iterations: null + break: + every: [6, 10] # passes (resting offers persist; state = GE offers + per-slot record) + for: [60, 240] # seconds of idle AFK + stop_conditions: + - "session GP target reached" + - "capital under floor" + - "no net-positive candidates on 3 consecutive scans" + - "3 consecutive failed/aborted passes (client issue — do not loop)" + + # ================= WORKED EXAMPLE (pinned, deterministic — owner addendum 2026-08-28; slot-machine walk per governor 10323, corrected per 10390) ================= + # Members account, 8 GE slots, capital 22,500,000 gp. Snapshot prices 2026-08-27 + # (buy/sell = low/high edge; tax = min(floor(0.02 x sell), 5,000,000)). + # The run below walks THREE slots in detail (arrowtips / iron / cosmic) exactly as + # the committed scheduler-dispatch + conservation fixtures; the other five slots + # behave identically to the iron slot and are omitted for brevity. + # Ledgers are SIDE-AWARE throughout: a BUY's acquired items are `filled` ONLY; + # unfilled/cancelled quantity is CASH (spent / still_committed / refunded). + # + # t0 idle -> scan -> schedule (pass 1): all slots free. Scheduler dispatch: + # free -> quote. Slot ledger after the pass (conservation holds): + # arrowtips: buy-open, quoted 10,000 @ 223; cash_quoted 2,230,000 + # = spent 0 + still_committed 2,230,000 + refunded 0 + # iron: buy-open, quoted 10,000 @ 145; cash_quoted 1,450,000 + # cosmic: buy-open, quoted 18,000 @ 122; cash_quoted 2,196,000 + # Unit buckets: quoted == still_open for every slot; filled/cancelled = 0. + # + # t1 wait, ~18 min: arrowtips PARTIAL fill 4,000/10,000 (a6 verdict PARTIAL). + # BUY ledger: quoted 10,000 == filled 4,000 + still_open 6,000 + cancelled 0. + # Acquired items = 4,000 ONLY; they sit in the GE collection box (collectible + # at any bank), while the offer keeps resting with 6,000 still open. Cash: + # cash_quoted 2,230,000 == spent 892,000 + still_committed 1,338,000. + # limit_ledger: first_fill_ts now, limit_used 4,000, limit_resets_at = +4h, + # remaining 6,000 (window CAPACITY — not an offer deadline). + # Iron: FULL fill 10,000/10,000 -> buy-filled-awaiting-collect (units sit in + # the box until ge_collect; the slot cannot sell them directly — finding 2). + # Iron cash: spent 1,450,000. Cosmic: no fill; offer keeps resting (edge + # positive, quote age within policy — finding 1: no-fill alone never aborts). + # + # t2 cadence -> schedule (pass 2): dispatch ONLY the dispatchable slot: + # iron buy-filled-awaiting-collect -> ge_collect (a9): withdraw 10,000 units; + # ledger reconciled (quoted 10,000 == filled 10,000; cash spent 1,450,000, + # no refund); sell edge positive -> ready-to-sell. + # arrowtips buy-open -> NO ACTION (resting, partial fill still open). + # cosmic buy-open -> NO ACTION (resting, no fill, edge positive). + # => the pass never closes or advances unrelated slots (regression fixture). + # + # t3 pass 3: iron ready-to-sell -> sell-open (a7): 10,000 @ 157 (high edge). + # + # t4 iron sell-open FULL fill -> sell-filled-awaiting-collect: proceeds + # 1,570,000 sit in the GE collection box until ge_collect. SELL ledger: + # quoted 10,000 == sold 10,000 + still_open 0 + returned_unsold 0. + # + # t5 pass 4: iron sell-filled-awaiting-collect -> ge_collect (a9): withdraw + # 1,570,000; tax floor(157 x 0.02) = 3/item -> net (157 - 3 - 145) x 10,000 + # = 90,000. proceeds_in_box -> cash_collected; ledger reconciled and + # ARCHIVED (the immutable cycle record: quoted 10,000 == sold 10,000); + # sub-state -> closed, and the SAME pass's archival dispatch releases the + # physical slot: closed -> free (governor 10471). The slot can place the + # next flip's buy immediately — it is never stranded after one flip. + # Conservation: quoted 10,000 == sold 10,000, zero proceeds left uncollected. + # + # t6 LIMIT RESET, NOT AN OFFER EVENT (finding 1): arrowtips reaches + # limit_resets_at with 6,000 still open on the resting offer. The window + # reset restores buying capacity ONLY — the offer KEEPS RESTING at its edge; + # nothing is cancelled and the 4,000 filled units stay collectible in the + # box. Cosmic likewise unaffected. + # + # t7 arrowtips offer FULLY fills the remaining 6,000 -> buy-filled-awaiting- + # collect -> pass 5 ge_collect: withdraw 10,000 units (4,000 from the t1 + # partial + 6,000 from the t7 fills — the units are what the offer actually + # bought; NO phantom "refunded units"). BUY ledger: quoted 10,000 == filled + # 10,000; cash 2,230,000 == spent 2,230,000 (still_committed 0 — a full fill + # spends its whole quote). Sell edge positive -> ready-to-sell. + # limit_ledger: window 1 used 4,000 (t1); window 2 (post-reset) used 6,000. + # + # t8 pass 6: arrowtips ready-to-sell -> sell-open 10,000 @ 244 -> full fill -> + # sell-filled-awaiting-collect -> pass 7 ge_collect: proceeds 2,440,000; + # tax floor(244 x 0.02) = 4/item -> net (244 - 4 - 223) x 10,000 = 170,000 + # (matches the 170,000 4h/slot table row) -> closed (ledger archived) -> + # closed -> free: the arrowtips physical slot is released for the next flip. + # + # t8b SECOND ROTATION in the SAME physical slot (governor 10471 regression): + # the iron slot released at t5 and the arrowtips slot released at t8 are + # free and dispatchable again — pass 8 quotes a NEW arrowtips buy in the + # iron slot (free -> buy-open, a4; buy_capacity_ok re-checked against the + # CURRENT window's limit_ledger). Rotation 1's archived ledgers (iron + # closed record, arrowtips closed record) remain archived and bit-for-bit + # untouched — no loss, duplication, or mutation — while the live ledger is + # a fresh rotation-2 record (quoted 10,000, cash_quoted 2,230,000, + # still_open 10,000). This is the committed slot_reuse regression: a slot + # completes one flip, is released, and runs the next one. + # + # t9 STALE-EDGE recovery (cosmic, finding 1 cancel trigger): at a cadence check + # the cosmic LOW edge has risen 122 -> 126: net 128 - floor(128 x 0.02)=2 - + # 126 = 0 -> margin no longer positive -> quote_age_policy aborts the offer + # (ge_cancel, a8): ZERO units were filled, so the collection box receives + # refunded CASH 2,196,000 and NO items. BUY ledger: quoted 18,000 == filled 0 + # + still_open 0 + cancelled_unfilled 18,000; cash 2,196,000 == spent 0 + + # still_committed 0 + refunded 2,196,000. Sub-state -> buy-cancelled- + # awaiting-collect. (The 18,000 never-purchased units are cash, never + # "refunded units" — the phantom-items fixture.) + # + # t10 pass 8: cosmic buy-cancelled-awaiting-collect -> ge_collect: withdraw + # refunded CASH 2,196,000; ledger reconciled (refunded -> collected). The + # fresh-edge margin is still 0, so the slot is NOT re-quoted: ready-to- + # requote requires a positive margin — decide free, capital reallocated to a + # candidate that still clears (e.g. magic logs); a requote in a later window + # is governed by buy_capacity_ok as usual (the cancel did NOT renew the + # window, and the next reset does not cancel anything). + # + # t11 FIRST SCHEDULED BREAK after 6-10 passes (midpoint 8): every slot is + # resting (buy-open / sell-open), free, or collected (closed) — nothing + # dispatchable; per-slot records persisted (slot_state_machine.break_resume); + # take-break 60-240 s (a10); RESUME at a2 (re-open GE) -> a6 (check-fills) + # -> schedule (next pass) -> continue per-slot from persisted sub-state. + # Breaks never strand an offer and never interrupt a collect/place. + # + # t12 STOP: session target (e.g. 900,000 net) -> collect -> complete; capital + # fell below floor -> idle -> complete; 3 consecutive failed passes -> failed. + # + # Rate qualification (typed ceiling vs realized scenarios — finding 5): + # ceiling 149,500 = 598,000 per 4h / 4h — ideal full fills of all 8 slots, + # zero overhead. Realized HIGH 138,000 = 149,500 x 37/40 (37 min resting waits + # + 3 min scan/place/collect per 40-min rotation at the 600 s cadence). + # Realized LOW 74,750 = 149,500 x 0.50 (requote-heavy: every 4h window's + # volume completes only after one ge_cancel + requote cycle — the no-fill + # remainder is refunded and re-quoted on the next window, halving volume). + # The ceiling is NEVER presented as an achieved runtime rate. + # ===================================================================================================== + +risk: + ban_level: low + notes: + - "Flipping is standard gameplay; the detectable signal is quote churn — keep per-offer pacing humanised, never instant-buy/sell loops." + - "The 2% fee (since 2025-05-29) and 5M/item cap make sub-2%-spread staples unprofitable at band edges; re-scan before every rotation." + - "Thin/volatile items: a resting offer can sit for hours — offer lifetime is governed by quote_age_policy (abort only on a stale scanned edge or strategy reallocation), NEVER by the 4h buy-limit window. A limit reset merely restores buy capacity; it cancels nothing." + - "Partial fills are normal: filled units of a partial buy sit in the GE collection box (collectible at any bank) while the offer keeps resting; never cancel-and-replace a partially-filled offer mid-window just to churn — the side-aware ledger keeps the filled qty attached until the offer fully fills or is aborted per quote_age_policy." + - "ge_cancel NEVER returns items/coins directly to inventory: everything lands in the GE collection box. BUY cancels return filled items + refunded CASH (the unfilled quantity is cash, never 'refunded units'); SELL cancels return unsold units + proceeds received. Reuse a slot only after ge_collect + side-aware ledger reconciliation (conservation invariants)." + - "The 4h buy limit is independent of offer life: cancelling does not renew it and the reset does not cancel offers. A slot at limit_used == limit cannot place a new/requoted BUY until limit_resets_at (first_fill_ts + 4h); its resting offer is unaffected." + - "Breaks are safe only when no slot is dispatchable (resting offers or post-collect points, see a10 and break_resume); state = GE offers + the persisted per-slot record, which survive the pause." + - "A completed cycle NEVER strands a slot: after ge_collect the ledger is reconciled + archived at 'closed' and the scheduler pass releases the physical slot to free (closed -> free archival release), so every slot keeps running flip after flip. Never model a slot as one-flip-then-done (governor 10471)." + +sources: + - "OSRS wiki — Grand Exchange (2% convenience fee since 2025-05-29, capped 5M/item, sub-50gp effectively untaxed, tax-exempt low-value items; buying rate restricted per 4h window starting on the FIRST purchase of the window; connected limits; verified 2026-08-27)" + - "OSRS wiki — Grand Exchange/Buying limits (4h window mechanics; the limit controls how many units may be bought and resets 4h after the first unit is bought; outstanding offers remain active until filled or explicitly aborted — a limit reset is NOT offer expiry)" + - "OSRS wiki — Grand Exchange + Bank (collection interface): items/coins from full or partial trades — including aborted offers — remain in the collection box until collected; partially completed trades can be collected at any bank" + - "OSRS wiki — Merchanting (flipping = spread exploitation; high trade volume = safety; bulk flipping needs ~20M+ cash; avoid static-price items; offer matching at or better than your price)" + - "prices.runescape.wiki/api/v1/osrs/mapping (item ids, members flags, limits — limits cross-checked against Module:Exchange/, all agree)" + - "prices.runescape.wiki/api/v1/osrs/latest (high/low price band snapshot 2026-08-27)" + - "governor review comment 10323 on PR #7 (2026-08-28): per-slot asynchronous sub-state machine + scheduler pass (finding 3); ge_cancel -> collection box + ge_collect + reconciliation (finding 2); buy-limit ledger distinct from offer expiry, cancellation never renews (finding 1); conservation invariants (finding 4); typed ceiling vs documented realized scenarios (finding 5); explicit scheduler iteration + persisted break/resume, no slot downgrade (finding 6)" + - "governor review comment 10390 on PR #7 (2026-08-28): the 4h limit reset never cancels offers — resting offers remain open while the margin is positive (finding 1); filled offers must pass through explicit *-filled-awaiting-collect states and ge_collect before reuse/close (finding 2); side-aware ledgers kill the phantom-units error (finding 3); side-aware conservation with negative fixtures, including sale-before-collection and close-before-proceeds-collection (finding 4)" + - "governor review comment 10471 on PR #7 (2026-08-29): a completed flip must NOT permanently strand the GE slot — after proceeds are collected and the completed ledger is archived/reconciled the physical slot returns to free via the archival transition closed -> free (also for a deliberately closed cancelled sell); committed two-rotation regression (buy/collect/sell/collect -> archive -> second buy in the same slot) with the archived ledger preserved" + - "owner authorisations 2026-08-28/29 (guidance comments 10264, 10271, 10334, 10527/10528): capped/floored global tax formula; complete lifecycle contract; continued full-lifecycle repair on the same branch/PR (retry-budget superseded)" + - "docs/action-grammar.md (ge_cancel/ge_collect rows, 2026-08-28) + schemas/entry.schema.json (slot_state_machine, ceiling_gp_per_hour)" +verified: "2026-08-27" # keep quoted (YAML 1.1 date parsing); prices last re-fetched live 2026-08-27 + +related: + - low-alch-f2p + - f2p-alch-catalog + - mule-topup diff --git a/codex/economy/low-alch-members.yaml b/codex/economy/low-alch-members.yaml new file mode 100644 index 0000000..261f77d --- /dev/null +++ b/codex/economy/low-alch-members.yaml @@ -0,0 +1,490 @@ +id: low-alch-members +title: Low-Level Alchemy (Members) +domain: economy +tags: [alching, magic-training, members, loss-training] +status: verified +version: 6 # 6: bounce-fix 5 2026-08-29 (owner guidance for governor 10361) — loss-reserve stop fixed to 'initial capital - total assets > 162,990' everywhere + validator boundary fixtures (below/at/above/profit) + realized GP/hr bounds derived from declared fill-price scenarios (A live-low -3.00/cast, B max allowed +5% -30.15/cast): gp low -29,463 / high -1,758. # 5: bounce-fix 4 2026-08-28 (owner guidance 10325) — complete GE acquisition path (open -> place -> fill -> collect) for initial buy AND every restock (bank-stock fast path only when the bank holds stock); fail-closed staff equip (bounded retries, no fire-rune fallback); capital model (cash vs supplies, staff cost, max-fill worst case, 6-batch loss reserve, float); monotonic break/restock counters (breaks 550/1,100/1,650 independent of restocks 901/1,802) + validator regression; startup-vs-steady-state rates over a 2 h session horizon +updated: "2026-08-29" # ISO dates must stay quoted — YAML 1.1 parses bare dates to datetime.date + +summary: > + Magic training from level 21 by low-alching members items with the lowest + loss per cast (high-limit, high-volume candidates first). Same state + machine and actions as low-alch-f2p; the catalog is the members item pool + (mapping lowalch != null, members=true, live GE prices 2026-08-27). At + current prices the method burns a small amount of GP per cast for 31 XP — + run it for XP, not GP. Least-loss liquid anchor at last check: rune + javelin tips (live-low net -3/cast; realized loss band -1,758 to -29,463 + gp/hr across the declared live-low and max-allowed +5% fill scenarios — + XP is fill-independent at 23,000/hr session-realized midpoint). Model + pins (owner guidance 2026-08-28): one + iteration = one cast; breaks every 400-700 COMPLETED CASTS (cast-count + based, independent of bank visits — midpoint example: 550, 1,100, 1,650, + ...); restock when candidate or nature drops below the numeric + restock_threshold (100) against a carried_batch of 1,000/1,000 (restock + cadence 901 casts, monotonic and independent of breaks). EVERY purchase + follows the complete GE path open -> place -> fill/wait -> collect, for + the initial acquisition and for every restock (a bank-stock fast path + applies only when the bank actually holds enough stock). Staff-of-fire + equip failure is FAIL-CLOSED (bounded retries, then log ERROR + alert + + stop — no fire-rune fallback is modeled). Session horizon 2 h; rates are + session-realized (startup + restock + breaks included, see economy). + Handoff: switch to High Level Alchemy at level 55 (hard level gate + + strictly more coins AND XP per cast; see sources). + +accounts: + membership: members + min_level: 21 # Magic 21 to cast Low Level Alchemy (wiki-verified) + requires_quest: [] + requires_other: + - "Staff of Fire equipped (removes 3 fire runes from the cost; NO level requirement documented on the OSRS wiki — verified 2026-08-28, see sources — this is the default path). Equip failure is FAIL-CLOSED: flow performs bounded retries (3), then logs ERROR + alerts + stops — there is no fire-rune fallback in this model" + - "A bank with GE access within reach (alch spot = GE bank booth; restock trip [60, 120] s)" + - "Starting capital >= ~1.31M gp CASH (model breakdown: staff buy hard cap 5,000 + first batch at MAX allowed fill 570,150 + session loss reserve 162,990 = 6 worst-case batches at live-low+5% fills (covers Magic 21 -> 55) + resting-offer float 570,150). The capital stop check uses TOTAL ASSETS (cash + supplies at current buy cost), never raw cash — cash legitimately converts into supplies mid-run" + - "30 Attack + 50 Magic ONLY if adopting the optional Bryophyta's staff + Tome of Fire technique — below that the Staff of Fire path applies" + +best_practice: + equipment: + - item: staff of fire + why: "unlimited fire runes — LA rune cost drops from 3 fire + 1 nature to 1 nature per cast (~15gp/cycle saved); equip requirement: none documented on the OSRS wiki (verified 2026-08-28); if it cannot be equipped after bounded retries the run FAILS CLOSED (no fallback modeled)" + source: "OSRS wiki — Staff of Fire (id 1387, F2P item, tradeable, no requirements row in the infobox and none in the prose — compare Battlestaff which states 'requires 30 Attack and 30 Magic'); Low Level Alchemy (Cost)" + techniques: + - name: cast on noted (stacked) items + why: "one inventory slot, minimal mouse travel between spell icon and item, no item un-noting; means an inventory does NOT define a batch size — batch sizes are the explicit numeric carried_batch below" + source: "OSRS wiki — Low Level Alchemy (Usage: casting alchemy spells on noted items)" + - name: Bryophyta's staff + Tome of Fire (members-only, 50 Magic, 30 Attack) + why: "charged Bryophyta's staff holds up to 1,000 nature runes (0 rune inventory slots) with a 1/15 chance per cast to not consume a nature rune; charged Tome of Fire supplies infinite fire runes in the shield slot and non-combat spells (alchemy) do not consume charges — the wiki documents this exact pair for alchemy profit; payback takes thousands of casts, so adopt at 50+ Magic when nature prices are high. EQUIP GATE: the staff requires 30 Attack + 30 Magic and the tome requires 50 Magic — accounts below 30 Attack (e.g. 1-Attack mules) cannot equip it and keep the ordinary Staff of Fire path (equipment section above) instead" + source: "OSRS wiki — Bryophyta's staff (passive effect; 'can be used along with a tome of fire to increase profits with High Level Alchemy'); OSRS wiki — Tome of Fire (level 50 Magic, non-combat spells do not consume charges)" + - name: self-craft nature runes as a cost cap + why: "members with 44 Runecraft can craft natures from pure essence, capping the per-cast rune cost at essence+time when GE nature prices spike; use the Abyss (no talisman needed) from the Mage of Zamorak" + source: "OSRS wiki — Nature rune (level 44 Runecraft, pure essence, 9 xp); OSRS wiki — Abyss (runic altar access)" + +economy: + # Verified 2026-08-27 against prices.runescape.wiki (live 'latest') + + # OSRS wiki (spell infoboxes, Module:Exchange limits, item pages). + # Re-verified at bounce fix 2026-08-27 with a fresh live fetch; the + # modeled anchor is the ACTUAL least-loss liquid candidate. + # Bounce-fix 4 (2026-08-28, owner guidance 10325): all rate fields are + # SESSION-REALIZED figures — startup + restock + mandatory breaks + # included; loop-only and steady-state figures retained as labelled + # references; the 2 h session horizon is explicit (stop_conditions). + item: Rune javelin tips # least-loss liquid anchor 2026-08-27 (see profit_drivers) + item_id: 19580 + cost_per_cycle: 543 # javelin tips buy 403 + nature rune 140 (staff of fire, live low 2026-08-27) + income_per_cycle: 540 # lowalch of rune javelin tips (40% of value 1350) + net_per_cycle: -3 + gp_per_hour_low: -29463 # WORST-CASE realized: max-allowed fill scenario B (-30.15/cast) x session-fast 977.2 casts/hr = -29,462.6 (3.4 s cycle, 300 s startup, 2 h horizon) — see FILL-PRICE SCENARIOS below + gp_per_hour_high: -1758 # BEST-CASE realized: live-low fill scenario A (-3.00/cast) x session-slow 585.9 casts/hr = -1,757.7 (5.2 s cycle, 600 s startup, 2 h horizon) — see FILL-PRICE SCENARIOS below + xp_per_hour: 23000 # session-realized midpoint: 741.8 casts/hr x 31 = 22,995 (pin 23,000) + # ---- rate model (pinned; owner guidance 2026-08-28) ---- + # LOOP (idealised, zero overhead — ceiling reference ONLY, never the + # entry's output): cycle = 1.8 s cast (3 ticks x 0.6 s) + + # anti_ban.delay [1.6, 3.4] s ADDITIVE (docs/action-grammar.md:135) + # = 3.4-5.2 s, midpoint 4.3 s -> loop-only casts/hr = 3600/4.3 = 837.2 + # (range 692.3..1,058.8); loop-only xp mid = 25,953; gp mid = -2,512. + # STEADY-STATE (mandatory breaks + restock trips, no startup): per + # 901-cast batch: 901 x cycle + restock trip [60, 120] s (mid 90) + + # break share (break.for mid 82.5 s per break.every mid 550 casts: + # 45 s/700 at the fast end, 120 s/400 at the slow end): + # fast 3.4 s: 901 x 3.4 + 60 + 45 x 901/700 = 3,181.3 s -> 1,019.7 casts/hr + # mid 4.3 s: 901 x 4.3 + 90 + 82.5 x 901/550 = 4,099.5 s -> 791.3 casts/hr + # slow 5.2 s: 901 x 5.2 + 120 + 120 x 901/400 = 5,075.5 s -> 639.2 casts/hr + # SESSION-REALIZED (2 h horizon = 7,200 s; one-time startup 300-600 s, + # mid 450 s -> 450/7200 = 6.25 %): realized = steady-state x (1 - + # startup/session): fast 977.2, mid 741.8, slow 585.9 casts/hr. + # xp mid = 741.8 x 31 = 22,995 -> pin 23,000. REALIZED GP/hr derives from + # the FILL-PRICE SCENARIOS below (the SAME ledger as the batch economics): + # worst = -29,463 = scenario B x session-fast 977.2 (-30.15 x 977.2 = -29,462.6) + # best = -1,758 = scenario A x session-slow 585.9 (-3.00 x 585.9 = -1,757.7) + # midpoints: scenario A x 741.8 = -2,225; scenario B x 741.8 = -22,365. + # BATCH ECONOMICS (901 casts; deposits 901 x 540 = 486,540 gp per batch): + # input cost at live low: 901 x 543 = 489,243 -> net -2,703/batch + # input cost at MAX fill (live low + 5 %): 901 x 570.15 = 513,705 -> net + # -27,165/batch (worst case). Reserve = 6 worst-case batches (Magic + # 21 -> 55 = ~5,300 casts = ~6 batches) = 162,990 gp; the capital stop + # checks TOTAL ASSETS, not raw cash (see accounts + stop_conditions). + # FILL-PRICE SCENARIOS (one ledger for capital, batch economics, AND the + # realized GP bounds — every number below comes from the same scenario): + # A live-low fill: candidate 403 + nature 140 = 543/cast vs + # 540 lowalch -> net -3.00/cast (the anchor) + # B max allowed fill (+5%): candidate 403x1.05 = 423.15 + nature + # 140x1.05 = 147 -> 570.15/cast vs 540 lowalch + # -> net -30.15/cast (the executable buy policy + # permits live low + 5%; this scenario, not the + # -3 live-low margin, bounds the downside) + # gp_per_hour_low = -29,463 | gp_per_hour_high = -1,758 derive from these + # scenarios at the session rates above; the batch ledger uses the same + # 543 / 570.15 per-cast costs (901-cast batch: -2,703 live-low, -27,165 + # max fill), so capital accounting and GP bounds can never diverge. + supplies: + - item: rune javelin tips + qty_per_cycle: 1 + source: ge + ge_limit: 10000 # mapping + Module:Exchange/Rune javelin tips agree (re-verified 2026-08-27); one carried_batch of 1,000 fits the 4h window + - item: nature rune + qty_per_cycle: 1 + source: ge + ge_limit: 18000 + profit_drivers: + - "No guaranteed profit to chase — this is loss-minimizing training: XP is the product. Pick the least-loss (least-negative net) candidate live each run." + - "Least-loss liquid anchors 2026-08-27 (net = lowalch - low buy - nature 140): rune javelin tips (19580, -3/cast, limit 10,000) is the modeled anchor; rune arrow (892, -25/cast, limit 11,000) is the runner-up." + - "Jade ring (21084) is currently MARGINAL-PROFIT (+67/cast, low/high 303/326) — flips sign as prices move, so it is not the loss anchor. Opal amulet (u) (21099) shows a 20x low/high spread (17/365) = stale-low artifact, disqualified per data-sources.md thin-fill trap." + - "Alch-margin candidates by (lowalch - buy) 2026-08-27 (thin limits cannot sustain a run; 'low' fills on thin items can be stale):" + - "1. Trailblazer top (t1) (id 25031, lowalch 6000, buy 555, margin +5445, GE limit 5)" + - "2. Twisted trousers (t2) (id 24401, lowalch 4000, buy 100, margin +3900, GE limit 5)" + - "3. Adamant hasta(p) (id 11407, lowalch 832, buy 1, margin +831, GE limit 125)" + - "4. Adamant spear(p++) (id 5726, lowalch 832, buy 10, margin +822, GE limit 125)" + - "5. Bagged flower (id 8451, lowalch 2000, buy 1250, margin +750, GE limit 10000 — deep limit but thin market: a 10k fill at 1,250 is unverifiable, do not model a run on it)" + - "6. Dual sai (id 23206, lowalch 2400, buy 1750, margin +650, GE limit 8)" + - "7. Frying pan (id 7441, lowalch 664, buy 101, margin +563, GE limit 50)" + - "8. Maple blackjack(d) (id 6420, lowalch 640, buy 100, margin +540, GE limit 125)" + - "9. Adamant spear(p) (id 1259, lowalch 832, buy 450, margin +382, GE limit 125)" + - "10. Mystic mist staff (id 20733, lowalch 17000, buy 75727, margin -58727, GE limit 8) — buy spiked ABOVE alch value 2026-08-27; a true loss trap, do not buy" + - "Caveat: thin items (limits 5-125) cannot sustain a run and their 'low' fills can be stale — a bot cannot reliably buy at those prices. The dependable loss-minimizers are the liquid high-limit pair modeled above: rune javelin tips (-3/cast) and rune arrow (-25/cast)." + - "Batch-scale economics (default staff-of-fire path, worst case 1 nature/cast): carried_batch 1,000/1,000, restock every 901 casts — one batch = -2,703 gp at live-low fills (-27,165 at MAX allowed fills), +27,931 xp, deposits 486,540 gp; unfilled-offer float (up to 570,150 gp) is separate from loss (see accounts)." + +state_machine: + initial: idle + terminal: [complete, failed] + states: + idle: + goal: "session start at the GE bank booth. Open GE (a2), then place the initial offers: staff of fire (a3), candidate 1,000 (a4), nature runes 1,000 (a5) at live low + 5% (staff: +25% up to the 5,000 cap). Record initial capital in the cash ledger (total assets = cash + supplies at current buy cost). Nothing is bought or collected without an open GE interface" + next: [wait_offers, complete, failed] + wait_offers: + goal: "wait for the pending offers (ge_offers_done); when filled, collect every purchase + refund from the collection box (a6 — never leave purchases in the box). PARTIAL FILLS: collect the filled portion, re-place the remainder at the current edge (a3/a4/a5); 3 consecutive no-fill rounds (offer cannot fill at the accepted edge) -> failed. An offer that is still unfilled at session end is left resting by design — its coins are tied up (float reserve) and return when it fills or is aborted by the operator (the single operator-touch point)" + next: [ready, wait_offers, failed] + ready: + goal: "equip the staff of fire (a7 — bounded retries, then FAIL CLOSED) and build the loadout to carried_batch 1,000 candidate + 1,000 nature (a8; purchases from a6 are already in the inventory; a8 deposits overflow and confirms the counts)" + next: [casting, failed] + casting: + goal: "cast Low Level Alchemy on the candidate (a9) — each cast consumes 1 candidate + 1 nature and pays 540 gp (lowalch), +31 xp, net -3 gp; take a break when the completed-cast counter hits run_control.break.every ([400, 700] casts, via a13); the counter is NEVER reset by restocking" + next: [bank_supplies, complete, failed] + bank_supplies: + goal: "restock to carried_batch: deposit coins + products and withdraw ALL candidate/natures the bank holds (a10 — bank-stock fast path when the bank already holds enough); for the remaining deficit, open GE (a2), buy the deficit (a11, live low + 5%), collect it (a12) — never withdraw a batch that was never bought. Restocking never resets the cast counter or the break counter; the break cadence (550/1,100/1,650, ...) is independent of the restock cadence (901/1,802, ...)" + next: [casting, bank_supplies, failed] + complete: + goal: "reached the run's XP/level target (default 55 -> hand off to HLA) or the 2 h session horizon" + failed: + goal: "hard failure — stop, log ERROR sentinel, alert" + transitions: + - from: idle + when: "staff + candidate + nature offers all placed (a3/a4/a5)" + to: wait_offers + - from: idle + when: "magic level >= target (55) at session start" + to: complete + - from: idle + when: "3 consecutive offer-place failures at session start (no fallback candidate)" + to: failed + - from: wait_offers + when: "ge_offers_done AND all purchases collected (a6)" + to: ready + - from: wait_offers + when: "partial fill: filled portion collected (a6), remainder re-placed at the current edge (a3/a4/a5) — still waiting" + to: wait_offers + - from: wait_offers + when: "3 consecutive no-fill rounds (offer cannot fill at the accepted edge)" + to: failed + - from: ready + when: "staff equipped (a7) AND loadout at carried_batch 1,000/1,000 (a8)" + to: casting + - from: ready + when: "staff equip failed after bounded retries — FAIL CLOSED" + to: failed + - from: casting + when: "candidate count < restock_threshold (100) OR nature count < restock_threshold (100) — deterministic restock trigger, pinned numbers" + to: bank_supplies + - from: casting + when: "magic level >= target (55, hand off to HLA)" + to: complete + - from: casting + when: "3 consecutive failed casts (client issue — do not loop)" + to: failed + - from: bank_supplies + when: "restock complete: candidate == carried_batch (1,000) AND nature == carried_batch (1,000) — bank fast path or re-bought deficit collected" + to: casting + - from: bank_supplies + when: "restock partial: deficit re-bought but offers still filling (ge_offers_done false) — loop the restock phase" + to: bank_supplies + - from: bank_supplies + when: "3 consecutive no-fill restock rounds OR a capital stop fires (assets / loss-reserve checks in stop_conditions)" + to: failed + +# --- handoff note (wiki-verified 2026-08-27, do not copy from other entries) --- +# Low Level Alchemy: level 21, 31 xp/cast, 1 nature + 3 fire (spellbook: Normal). +# Superheat Item: level 43, 53 xp/cast, 1 nature + 4 fire — a SMITHING +# production spell (ore -> bar), NOT the magic-XP handoff; as pure Magic +# training it is only a soft side option (e.g. training Smithing alongside). +# High Level Alchemy: level 55, 65 xp/cast, 1 nature + 5 fire. +# Handoff: the LA page itself states "Players with level 55 Magic are +# generally recommended to use High Level Alchemy instead, as it provides +# more coins and experience per cast than Low Level Alchemy". So the switch +# is level 55: a HARD level gate (HLA uncastable below 55) plus strict +# dominance afterwards (with a fire staff both cost 1 nature/cast, and HLA +# pays 60% of value + 65 xp vs LA's 40% + 31 xp) — after 55, LA is strictly +# worse for Magic XP and is never the right call except when deliberately +# burning GP at minimum XP (never recommended). + +actions: + - id: a1 + step: reach-bank + verb: move_to + target: bank + params: + location: "GE-adjacent bank (alch spot = the bank booth, so restock is a single short leg)" + method: [teleport, walk] + on_fail: retry + anti_ban: + delay: [400, 1600] + + - id: a2 + step: open-ge + verb: ge_open + target: grand-exchange + params: + note: "open the GE interface at the booth. The entry NEVER buys (a3/a4/a5/a11) or collects (a6/a12) without an open GE interface — the action grammar defines ge_open, ge_buy, ge_collect as separate verbs, so the open step is explicit" + on_fail: retry + anti_ban: + delay: [600, 1400] + + - id: a3 + step: buy-staff + verb: ge_buy + target: grand-exchange + params: + item: "staff of fire" + qty: 1 + max_price: "live low + 25 % (wiki value 1,500; hard cap 5,000)" + note: "offer rests until filled or the run ends; on a partial/no fill, collect what filled (a6) and re-place the remainder at the current edge — bounded retries, then the run fails closed (no fire-rune fallback exists)" + on_fail: retry + anti_ban: + delay: [1200, 3000] + + - id: a4 + step: buy-supplies + verb: ge_buy + target: grand-exchange + params: + item: "current least-loss live candidate (default: rune javelin tips)" + qty: "carried_batch = 1,000 (fits the 4h GE limit window of 10,000)" + max_price: "live low + 5 %" + note: "offer rests until filled or the run ends; on a partial fill, collect the filled portion (a6) and re-place the remainder at the current edge" + on_fail: retry + anti_ban: + delay: [1200, 3000] + + - id: a5 + step: buy-natures + verb: ge_buy + target: grand-exchange + params: + item: "nature rune" + qty: "carried_batch = 1,000 (4h GE limit 18,000)" + max_price: "live low + 5 %" + note: "offer rests until filled or the run ends; partial fills handled like a4" + on_fail: retry + anti_ban: + delay: [1200, 3000] + + - id: a6 + step: collect-purchases + verb: ge_collect + target: grand-exchange + params: + note: "collect every filled purchase and refund from the GE collection box into the inventory — staff, candidate, natures (and the equivalent on every restock). Partial offers: the collected portion is added to the ledger; nothing is left in the box at session start" + on_fail: retry + anti_ban: + delay: [600, 1400] + + - id: a7 + step: equip-staff + verb: menu_option + target: "staff of fire in inventory" + params: + option: "Wield" + note: "Equip requirement is NONE documented on the OSRS wiki Staff of fire page (no requirements row in the infobox, none in the prose; compare Battlestaff which states 'requires 30 Attack and 30 Magic'; verified 2026-08-28). FAIL-CLOSED path: bounded retries (3), then log ERROR + alert + stop — no fire-rune fallback is modeled (never loop on equip attempts)" + on_fail: retry + anti_ban: + delay: [600, 1800] + + - id: a8 + step: build-loadout + verb: bank_inventory + target: bank + params: + deposit: [overflow products] + withdraw: "to carried_batch: candidate 1,000 + nature runes 1,000 (only the shortfall — collected purchases from a6 are already in the inventory; both stackable, two inventory slots)" + on_fail: retry + anti_ban: + delay: [500, 1100] + + - id: a9 + step: cast-alch + verb: use_item_on + target: "candidate item in inventory" + params: + spell: "Low Level Alchemy" + item: "least-loss live candidate in inventory" + on: "itself" + consumption: "1 candidate + 1 nature per cast (staff of fire default); pays 540 gp (lowalch) + 31 xp, net -3 gp" + on_fail: retry + anti_ban: + delay: [1600, 3400] # MANDATORY post-action sleep, ADDITIVE to the 1.8s cast (3 ticks x 0.6s; action-grammar.md:135): cycle = 1.8s + 1.6-3.4s = 3.4-5.2s, midpoint 4.3s -> session-realized ~742 casts/hr incl. startup + restock + breaks (pinned in economy) + + - id: a10 + step: restock-deposit + verb: bank_inventory + target: bank + params: + deposit: [all coins, all products] + withdraw: "ALL candidate + nature runes currently in the bank, up to carried_batch (1,000/1,000) — the bank-stock FAST PATH applies only when the bank actually holds enough stock; the remaining deficit is bought at the GE (a11) and collected (a12)" + on_fail: alert + anti_ban: + delay: [500, 1100] + + - id: a11 + step: restock-buy + verb: ge_buy + target: grand-exchange + params: + item: "current least-loss live candidate + nature runes" + qty: "deficit to carried_batch = carried_batch - inventory count after the a10 fast path (e.g. 901 candidate + 901 nature after a 901-cast batch with an empty bank)" + max_price: "live low + 5 %" + note: "the deficit is RE-BOUGHT at the GE on every restock — the entry never withdraws a batch that was never purchased; partial/failed fills use the same collect + re-place recovery as the initial acquisition" + on_fail: retry + anti_ban: + delay: [1200, 3000] + + - id: a12 + step: restock-collect + verb: ge_collect + target: grand-exchange + params: + note: "collect the re-bought deficit into the inventory — inventory returns to carried_batch 1,000/1,000 before casting resumes" + on_fail: retry + anti_ban: + delay: [600, 1400] + + - id: a13 + step: take-break + verb: take_break + target: "current location" + params: + duration: "[45, 120] seconds of idle AFK per break (matches run_control.break.for)" + note: "executed by the translator when the completed-cast counter reaches run_control.break.every — this IS run_control.break's executor, fired on CAST COUNTS, not on bank visits; the cast counter is NOT reset by restocking (breaks land at 550/1,100/1,650 in the midpoint example, independent of the 901-cast restock cadence)" + on_fail: skip + +run_control: + iteration: "one cast" # = one Low Level Alchemy cast (+31 xp, -3 gp at the anchor). Per docs/schema.md run_control, break.every counts ITERATIONS — so it counts CASTS here, NOT bank trips (owner decision 2026-08-28) + max_iterations: null # null = until complete condition + break: + every: [400, 700] # COMPLETED CASTS between AFK breaks (midpoint 550 = ~39 min at the 4.3s midpoint cycle). Cast-count-based and independent of bank visits; a restock trip NEVER counts as a break and does not reset this counter + for: [45, 120] # seconds of idle AFK per break + stop_conditions: + - "magic level >= target (55 -> hand off to HLA)" + - "session elapsed >= 2 h (session horizon — startup is amortised over this window in the rate model)" + - "total assets (cash + supplies at current buy cost) < 575,150 — cannot fund the next batch at max fill (570,150) plus staff replacement (5,000); assets, NOT raw cash: cash that became supplies is not a loss" + - "session net loss: initial capital - total assets > 162,990 — a loss makes 'initial capital - total assets' POSITIVE, so this strict-greater comparison fires on losses and never on profits; 162,990 = the 6-batch worst-case reserve (exactly AT the boundary it does NOT fire — strict >)" + - "3 consecutive failed casts (client issue — do not loop)" + - "3 consecutive no-fill GE rounds (initial acquisition or restock) with no fallback candidate available" + - "staff of fire equip failed after bounded retries — FAIL CLOSED (log ERROR, alert, stop)" + + # ================= WORKED EXAMPLE (pinned, deterministic — owner guidance 2026-08-28) ================= + # Constants: carried_batch = 1,000 candidate + 1,000 nature; restock_threshold = 100; + # break.every = [400, 700] casts — the example pins the MIDPOINT 550; + # break.for = [45, 120] s; restock cadence = 901 casts (1,000 - 99); the + # staff-of-fire path consumes 1 candidate + 1 nature per cast (worst case 1:1 — + # with Bryophyta's staff the nature side drains slower, so the candidate stays binding). + # The two counters are MONOTONIC and INDEPENDENT: breaks at 550, 1,100, 1,650, + # 2,200... (multiples of 550) and restocks at 901, 1,802, 2,703... (multiples of + # 901) — gcd(550, 901) = 1, so they interleave forever and restocking NEVER + # resets or shifts the break counter (validator regression: the old buggy + # 'restart the interval at the restock' example is rejected by the gate). + # + # Step 0 — preparation (one-time startup, ~300-600 s): account holds >= ~1.31M gp + # cash (staff cap 5,000 + first batch max 570,150 + reserve 162,990 + float + # 570,150). At the GE bank: open GE (a2); place offers — staff (a3), 1,000 + # rune javelin tips (a4), 1,000 nature runes (a5); wait for fills + # (ge_offers_done) and collect everything (a6); equip the staff (a7); loadout + # = carried_batch 1,000/1,000 (a8). Cast counter := 0; cash ledger opened. + # Step 1 — casts #1..#550: each cast consumes 1 candidate + 1 nature (-543 gp) + # and pays 540 gp (lowalch) + 31 xp -> net -3 gp. Cast counter 0 -> 550 + # (~39 min at midpoint). + # Step 2 — FIRST SCHEDULED BREAK at cast 550 (the interval since cast 0): + # take-break 45-120 s (a13). Resume casting at cast 551. The break fired on + # the CAST COUNT, not on any bank visit. + # Step 3 — casts #551..#901: countdown candidate 1,000-901 = 99 < 100 + # (restock_threshold) and nature 99 < 100 simultaneously (worst case 1:1) -> + # the transition fires at the end of cast #901: FIRST RESTOCK at cast 901. + # Deposit 901 x 540 = 486,540 gp (a10); the bank holds 0 supplies (all sales + # were GE buys), so the fast path yields nothing -> open GE (a2), RE-BUY the + # deficit 901 candidate + 901 nature (a11, live low + 5 %), collect (a12) -> + # inventory back to 1,000/1,000. Batch net: -2,703 gp (live-low fills) or + # -27,165 gp (max fills). The cast counter (901) and break counter continue. + # Step 4 — SECOND BREAK at cast 1,100: the interval since break #1 is exactly + # 550 casts (1,100 - 550); the cast-901 restock did NOT reset or shift it. + # Step 5 — THIRD BREAK at cast 1,650 (1,100 + 550); SECOND RESTOCK at cast + # 1,802 (901-cadence, 1,802 = 901 x 2). The sequences 550/1,100/1,650/2,200 + # and 901/1,802/2,703 interleave independently. + # Step 6 — continue until a stop_condition fires: Magic >= 55 (HLA handoff), + # the 2 h session horizon, the capital stops (assets floor 575,150, or + # loss = initial capital - total assets > 162,990), 3 failed casts, or 3 + # consecutive no-fill GE rounds. + # ============================================================================================ + +risk: + ban_level: low + notes: + - "High cast cadence is the main detection vector; keep the mandatory anti_ban delays (cadence pin: additive post-cast sleep [1.6, 3.4] s; session-realized ~742 casts/hr midpoint incl. startup + restock + breaks over a 2 h session; steady-state ~791; loop-only 837 is a ceiling reference, never the output)." + - "Breaks are CAST-COUNT-based (every [400, 700] completed casts, 45-120 s AFK) and independent of bank visits — never treat a restock trip as a break, and never reset the break counter on restock (break/restock gcd(550, 901) = 1)." + - "GE buy bursts: pace offers, respect the 4h limit window (10,000 javelin tips / 18,000 natures); thin items (limit < 100) cannot sustain a long run; offers persist until filled or aborted and a 4h limit reset is NOT an offer expiry." + - "Thin-item 'latest low' fills can be stale — confirm fill price with a live GE offer before committing a run; the liquid high-limit pair (rune javelin tips / rune arrow) is the dependable loss-minimizer." + +# --- recovery block (owner guidance 2026-08-28: every activity must cover its complete runnable lifecycle) --- +# - Missing materials / depleted supplies: the restock_threshold transition (casting -> +# bank_supplies at candidate OR nature < 100) catches both mid-run depletions; the +# restock phase (a10 -> a2 -> a11 -> a12) rebuilds to carried_batch and the loop +# resumes (worked example steps 3-4). The entry NEVER withdraws a batch that was +# never purchased — every restock re-buys the deficit at the GE. +# - GE offers: partial fills are collected (a6/a12) and the remainder is re-placed at +# the current edge (a3/a4/a5/a11); after 3 consecutive no-fill rounds (initial or +# restock) the run stops (failed) — never sit in an unfillable offer loop. Unfilled +# offers at session end are left resting by design (float reserve; coins return when +# they fill or the operator aborts them — the single operator-touch point). +# - Staff of fire cannot be equipped (a7): bounded retries (3), then FAIL CLOSED — +# log ERROR + alert + stop. No fire-rune fallback is modeled (it would need its own +# purchase/supply/rate path; the owner decision rejects a prose-only fallback). +# - Invalid loadout (wrong item in hand / spell unavailable): re-run build-loadout +# (a8); 3 consecutive failed casts -> failed (stop, log ERROR sentinel, alert). +# - Capital: the stop checks TOTAL ASSETS (cash + supplies at current buy cost), and +# both capital stops are assets-based (see stop_conditions) — cash converted into +# supplies is not a loss and never fires a stop by itself. Loss direction is pinned +# everywhere as 'initial capital - total assets > 162,990': a loss makes that +# difference positive, so the stop fires on losses and never on profits (the +# reversed sign form can never exceed the threshold on a loss and is rejected by +# the validator regression; boundary fixtures: below/at/above + profit side). +# - Explicit completion/stop: stop_conditions above (Magic 55 HLA handoff, 2 h session +# horizon, assets floor 575,150, loss reserve 162,990, repeated failures, fail-closed +# staff equip). + +sources: + - "OSRS wiki — Low Level Alchemy (level 21, 31 xp/cast, 3-tick, 1 nature + 3 fire; spellbook Normal; infobox 2026-08-27)" + - "OSRS wiki — High Level Alchemy (level 55, 65 xp/cast, 1 nature + 5 fire; 60% of item value; infobox 2026-08-27)" + - "OSRS wiki — Superheat Item (level 43, 53 xp/cast, 1 nature + 4 fire; smithing production spell; infobox 2026-08-27)" + - "OSRS wiki — Low Level Alchemy (handoff quote: 'Players with level 55 Magic are generally recommended to use High Level Alchemy instead, as it provides more coins and experience per cast than Low Level Alchemy')" + - "prices.runescape.wiki mapping 2026-08-27 (members/lowalch/highalch/limit catalog; rune javelin tips id 19580: lowalch 540, highalch 810, limit 10000, members=true; lowalch == highalch*2//3 asserted for every cited item)" + - "prices.runescape.wiki latest 2026-08-27, re-fetched at bounce fix (low = per-repo buy basis: rune javelin tips 403, rune arrow 45, nature rune 140, jade ring 303, opal amulet (u) 17)" + - "OSRS wiki — Module:Exchange/Rune javelin tips (limit 10000, lowalch 540, highalch 810, members=true — matches mapping; re-verified 2026-08-27) + Module:Exchange/Nature rune, Staff of fire and all cited candidates (GE limits agree with mapping; verified 2026-08-27)" + - "OSRS wiki — Rune javelin tips / Rune arrow / Bagged flower / Mystic mist staff etc. item pages (members = Yes for every cited catalog item; verified 2026-08-27)" + - "OSRS wiki — Grand Exchange (offers persist until filled or aborted; filled items and coin refunds are collected from the booth/collection box; partial fills are separately collectable) — verified 2026-08-28" + - "OSRS wiki — Grand Exchange/Buying limits (4-hour window; the limit resets 4 hours after the FIRST unit is bought; outstanding offers remain active and a limit reset is NOT an offer expiry) — verified 2026-08-28" + - "OSRS wiki — Nature rune (members craft at level 44 Runecraft from pure essence, 9 xp; Nature Altar south-east of Tai Bwo Wannai)" + - "OSRS wiki — Abyss (access to runic altars without talismans via the Mage of Zamorak)" + - "OSRS wiki — Bryophyta's staff (level 30 Attack + Magic; up to 1,000 nature charges; 1/15 chance to not consume a charge on any nature-rune spell; alchemy combo with Tome of Fire)" + - "OSRS wiki — Tome of Fire (members, level 50 Magic to wield; charged tome is an infinite fire-rune source and non-combat spells do not consume charges)" + - "OSRS wiki — Staff of fire (page verified 2026-08-28 for the equip requirement: id 1387, F2P item, tradeable, options Wield — NO requirements row in the Infobox Item and NO requirement statement in the prose; contrast Battlestaff, whose prose states 'requires 30 Attack and 30 Magic to wield')" + - "docs/schema.md run_control (iteration unit; break.every counts iterations) + docs/action-grammar.md:135 (anti_ban.delay = additive post-action sleep) + verbs ge_open/ge_buy/ge_collect/ge_offers_done (separate GE primitives, never assumed) — the cadence + break + GE + recovery model" +verified: "2026-08-27" # keep quoted (YAML 1.1 date parsing); prices last re-fetched live 2026-08-27 + +related: + - low-alch-f2p + - high-alch-p2p + - mule-topup diff --git a/codex/training/woodcutting-f2p.yaml b/codex/training/woodcutting-f2p.yaml index 479feb5..8526468 100644 --- a/codex/training/woodcutting-f2p.yaml +++ b/codex/training/woodcutting-f2p.yaml @@ -3,8 +3,8 @@ title: Woodcutting (F2P) domain: training tags: [woodcutting, gathering, f2p, skill-training] status: verified -version: 4 # 4: bounce-fix 3 2026-08-28 — owner decision 10288 implemented: three explicit modes (fast-xp / afk-xp / banked-income) with deterministic ladders, full lifecycle per mode, per-mode worked examples, realized rates incl. travel/respawn/banking-or-dropping/breaks; axe model corrected (41 Woodcutting to USE, 40 Attack to EQUIP — equip only when Attack permits, otherwise carry the same axe); structured axe_tiers / axe_examples / woodcutting_modes blocks + committed validator fixtures (incl. the 1-Attack/41-Woodcutting rune-axe carry case) -updated: "2026-08-28" # ISO dates must stay quoted — YAML 1.1 parses bare dates to datetime.date +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 @@ -68,6 +68,14 @@ axe_examples: 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 @@ -78,19 +86,16 @@ woodcutting_modes: banked-income target). 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} + - {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: - chop_seconds_per_log: 4.0 # humanised chop cadence incl. anti_ban delays; willow falls ~30s/tree, switch to the next interactable tree - drop_seconds_per_log: 0 - walk_seconds_each_way: 12 # Draynor bank <-> willow spot + drop_seconds_per_log: 0 # bank mode — no dropping deposit_seconds: 4 - respawn_duty: 0.92 # tree-switch + occasional shared-spot respawn pause - break_duty: 0.94 # scheduled breaks (run_control.break) land at the bank - rates: - logs_per_hour: 622 # 28 logs / (28x4.0 + 2x12 + 4 = 140s) x 3600 = 720; x 0.92 x 0.94 = 622.7 + 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 @@ -104,24 +109,23 @@ woodcutting_modes: 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} - - {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} + - {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: - chop_seconds_per_log: 6.0 # AFK idle-pace: slower reaction after each tree falls (maple falls ~60s) 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) - walk_seconds_each_way: 0 deposit_seconds: 0 - respawn_duty: 0.80 # 4 trees, 60s falls, distant spot; occasional all-down pause break_duty: 0.94 # scheduled breaks at the spot (drop mode has no bank) - rates: - logs_per_hour: 386 # 28 logs / (28x(6.0+1.0) = 196s) x 3600 = 514.3; x 0.80 x 0.94 = 386.8 + 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: 4632 # 386 x 12 (maple log sell value 2026-08-28; drop mode forgoes this unless the run optionally banks) + 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" @@ -135,26 +139,103 @@ woodcutting_modes: 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} - - {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} + - {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: - chop_seconds_per_log: 7.5 # yew chops slower per log (higher-level tree); conservative cadence drop_seconds_per_log: 0 - walk_seconds_each_way: 8 # Edgeville south yews <-> Edgeville bank deposit_seconds: 4 - respawn_duty: 0.52 # TWO-tree spot: yew falls ~114s, respawns ~59.4s; double-down waits + world contention — deliberately conservative (the guide's own caveat on yew spots) break_duty: 0.94 # scheduled breaks at the bank - rates: - logs_per_hour: 214 # 28 logs / (28x7.5 + 2x8 + 4 = 230s) x 3600 = 438.3; x 0.52 x 0.94 = 214.2 + 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)" @@ -206,21 +287,37 @@ state_machine: 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: [walk_to_trees, idle] + 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 a4" + 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 only: drop the whole load at the spot (menu_option per log group); inventory now empty; no bank legs" + 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)" + 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 (logs only — the axe stays equipped or carried, never deposited); short bank trip lands the scheduled break" + 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" @@ -234,25 +331,34 @@ state_machine: when: "mode + prerequisites confirmed and session target not reached" to: arm_run - from: idle - when: "session target already reached OR no axe obtainable (bank and GE both lack any F2P axe)" + 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: equip when Attack permits, else carry" - to: walk_to_trees + 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: "inventory full (28 log slots with the axe equipped, 27 with the axe carried — derived dynamically, never hardcoded)" + 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 banked (fast-xp | banked-income) and inventory full" + 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" + 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" @@ -281,13 +387,103 @@ actions: 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 a2)" + 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" @@ -298,64 +494,41 @@ actions: anti_ban: delay: [400, 1600] - - id: a3 - step: equip-or-carry-axe - verb: right_click - 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: "right_click axe -> menu_option 'Wield '" - capacity: "equipped: 28 log slots; carried: 27 (one slot for the axe)" - on_fail: retry - anti_ban: - delay: [300, 1100] - - - id: a4 - step: acquire-axe - verb: bank_inventory - target: "bank (then GE)" - params: - check: "withdraw the best axe the account can use (host has_item / item_count); if the bank lacks it -> ge_buy (rune axe ~7,293 high / 7,140 low live 2026-08-28, GE limit 40/4h — trivial)" - never: "deposit the axe at any point; a missing axe is a recovery event (stop -> alert) not a continue-with-lower-axe event" - on_fail: alert - anti_ban: - delay: [400, 1200] - - - id: a5 + - 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/bank; (d) no interactable tree for > 30s -> walk_to_trees move / hop_world; (e) click failed -> retry up to 3, then alert" - rhythm: "humanised cadence; per-mode chop_seconds_per_log in woodcutting_modes.model" + 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: a6 + - id: a12 step: check-inventory verb: item_count target: "logs in inventory" params: item: "logs of the pinned tier" - op: "inventory full -> drop (afk-xp) or walk_to_bank (fast-xp / banked-income); available log slots = 28 with the axe equipped, 27 with the axe carried — never hardcode 28" + 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: a7 + - 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 drop_seconds_per_log" + 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: a8 + - id: a14 step: walk-to-bank verb: walk_to target: "pinned bank from the tree spot" @@ -366,29 +539,29 @@ actions: anti_ban: delay: [400, 1600] - - id: a9 + - 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 a12" + break: "scheduled breaks land here (bank modes) — see a18" on_fail: alert - - id: a10 + - id: a16 step: walk-back-to-trees verb: walk_to target: "pinned tree spot from the bank" params: - route: "reverse of a8 — back to the same spot" + 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: a11 + - id: a17 step: move-or-hop-empty verb: hop_world target: "current spot" @@ -399,14 +572,14 @@ actions: anti_ban: delay: [800, 2200] - - id: a12 + - 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 -> chopping; drop mode -> chopping" + 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: @@ -423,21 +596,24 @@ run_control: # ================= 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). + # 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}. a3: rune axe (41 WC <= 50 -> usable), - # 40 Attack -> equipped (28 log slots). a4: axe already owned. - # a5 chop: 28 logs at ~4s each (112s) -> a6 inventory full -> a8 walk to - # Draynor bank (~12s) -> a9 deposit (4s) -> a10 walk back (~12s). + # 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), a12 take-break 45-150s LANDS AT + # 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: a10 walk back, + # 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. # @@ -445,37 +621,45 @@ run_control: # 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}. a3: 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. - # a5 chop until 27 logs (~162s at 6s each incl. post-fall reaction); tree - # falls ~60s so the spot's 4 trees rotate; a6 full -> a7 DROP the load at + # 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 = 386 - # logs/hr model; xp 38,600/hr; ~386 clicks/hr — the lowest-interaction - # mode. a12 break 45-150s AT THE SPOT (inventory empty, axe in hand) every - # 8-14 cycles, INDEPENDENT of the per-load dropping. Resume: a5 continues. + # ~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. + # (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}. a5 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). a6 full -> a8 walk to - # Edgeville bank (~8s) -> a9 deposit (4s) -> a10 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). a12 break 45-150s AT THE BANK every 8-14 cycles (mid ~11 = - # ~47 min). Session target: 1M gp banked = ~42h of cycles + breaks. + # 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-a4) -> first full-inventory boundary - # -> first scheduled break -> correct resume from the pinned activity - # state; tree depletion -> move/hop (a11); access denial / missing axe -> - # explicit recovery (a4, a10 spot_check, re-arm fallback). + # 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: @@ -497,8 +681,9 @@ sources: - "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)" + - "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: diff --git a/docs/action-grammar.md b/docs/action-grammar.md index 234e520..09cd594 100644 --- a/docs/action-grammar.md +++ b/docs/action-grammar.md @@ -68,8 +68,9 @@ Legend for the mapping table: | `ge_open` | open GE at a bank | — | — | — | | `ge_buy` | buy `qty` of `item` at offer price, paced | — | — | — | | `ge_sell` | sell `qty` of `item`, confirm | — | — | — | +| `ge_cancel` | abort an offer (quote_age_policy only — stale edge or strategy reallocation, never a 4h limit-window reset): BUY — filled items + refunded CASH (unspent remainder) enter the GE COLLECTION BOX; SELL — unsold units + proceeds received so far enter the box; NEVER directly to inventory; the 4h buy limit is NOT renewed | — | — | — | | `ge_offers_done` | predicate: no pending buy/sell offers | — | — | — | -| `ge_collect` | collect finished GE transactions | — | — | — | +| `ge_collect` | withdraw from the GE collection box: bought (filled) items, refunded CASH, unsold units, sale proceeds; reconcile the SIDE-AWARE per-slot ledger (buy: quoted = filled + still_open + cancelled_unfilled; sell: quoted = sold + still_open + returned_unsold) before any slot reuse; a completed cycle closes with the ledger reconciled + ARCHIVED and the same pass releases the physical slot to free (`closed -> free` archival transition), so the slot immediately runs the next flip (governor 10471) | — | — | — | | `ge_check_price` | read current buy/sell price of an item | — | — | — | ## F. Combat diff --git a/schemas/entry.schema.json b/schemas/entry.schema.json index b889189..4fdc7cf 100644 --- a/schemas/entry.schema.json +++ b/schemas/entry.schema.json @@ -43,6 +43,7 @@ "net_per_cycle": { "type": "number" }, "gp_per_hour_low": { "type": "number" }, "gp_per_hour_high": { "type": "number" }, + "ceiling_gp_per_hour": { "type": "number", "exclusiveMinimum": 0 }, "xp_per_hour": { "type": "number" }, "supplies": { "type": "array", @@ -62,6 +63,39 @@ } }, + "slot_state_machine": { + "type": "object", + "required": ["sub_states", "slot_transitions", "slot_transition_whens", + "scheduler_iteration", "quote_age_policy", "persisted_state", + "limit_ledger", "conservation", "break_resume"], + "additionalProperties": false, + "properties": { + "sub_states": { + "type": "array", + "minItems": 10, + "items": { "type": "string" } + }, + "slot_transitions": { + "type": "object", + "additionalProperties": { + "type": "array", + "items": { "type": "string" } + } + }, + "slot_transition_whens": { + "type": "array", + "minItems": 8, + "items": { "type": "string", "minLength": 8 } + }, + "scheduler_iteration": { "type": "string", "minLength": 10 }, + "quote_age_policy": { "type": "string", "minLength": 10 }, + "persisted_state": { "type": "string", "minLength": 10 }, + "limit_ledger": { "type": "string", "minLength": 10 }, + "conservation": { "type": "string", "minLength": 10 }, + "break_resume": { "type": "string", "minLength": 10 } + } + }, + "best_practice": { "type": "object", "required": ["equipment"], @@ -145,27 +179,30 @@ "minItems": 1, "items": { "type": "object", - "required": ["tree", "min_level", "gate", "spot", "bank"], + "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 } + "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": ["chop_seconds_per_log", "drop_seconds_per_log", "walk_seconds_each_way", "deposit_seconds", "respawn_duty", "break_duty"], + "required": ["drop_seconds_per_log", "deposit_seconds", "break_duty"], "additionalProperties": false, "properties": { - "chop_seconds_per_log": { "type": "number", "exclusiveMinimum": 0 }, "drop_seconds_per_log": { "type": "number", "minimum": 0 }, - "walk_seconds_each_way": { "type": "number", "minimum": 0 }, "deposit_seconds": { "type": "number", "minimum": 0 }, - "respawn_duty": { "type": "number", "exclusiveMinimum": 0, "maximum": 1 }, "break_duty": { "type": "number", "exclusiveMinimum": 0, "maximum": 1 } } }, @@ -184,6 +221,31 @@ } } }, + "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", @@ -244,7 +306,7 @@ "click_button", "menu_option", "bank_inventory", "deposit_all", "withdraw", "has_item", "count_item", "move_to_inv", "sort_inventory", - "ge_open", "ge_buy", "ge_sell", "ge_offers_done", "ge_collect", + "ge_open", "ge_buy", "ge_sell", "ge_cancel", "ge_offers_done", "ge_collect", "ge_check_price", "attack", "prayer", "buff", "eat", "retreat", "is_dead", "respawn", "is_at", "item_count", "level_is", "xp_is", "hp_is", diff --git a/tools/validate.py b/tools/validate.py index c48e09e..0cbbd83 100644 --- a/tools/validate.py +++ b/tools/validate.py @@ -1,30 +1,77 @@ #!/usr/bin/env python3 -"""Validate OSRS Codex skill/economy entries. +"""OSRS Codex validator — the gate for every codex/**.yaml entry. -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. +Runs: + 1. JSON-Schema validation (schemas/entry.schema.json) + 2. Cross-field consistency checks the schema can't express: + - state_machine `next`/transition targets must be declared states + - `initial` must be a declared state + - duplicate action ids / step names + - status:verified entries with `verified:` older than --stale-days + => WARNING (entry should be re-verified, not dropped) + - `best_practice` lines whose source starts with "researched" + => WARNING (citation TODO — file a follow-up issue) + - GE-margin entries (tags include flipping and/or merchanting — + the unambiguous margin signals): the 2% GE convenience fee is + rounded DOWN to whole coins per item and capped at 5,000,000 GP + per item, so net_per_cycle MUST equal + income - min(floor(0.02*income), 5_000_000) - cost. + Fractional-tax arithmetic (0.98*sell - buy) and uncapped tax + (6M on a 300M sale) are errors. + NOTE: the generic `ge` tag alone does NOT gate this rule — a + training/processing/procurement entry may legitimately carry + `ge` while its income/cost model has nothing to do with flip tax. + - GE-flip lifecycle (flipping/merchanting entries): the 4h buy-limit + ledger (limit_used / limit_resets_at) is DISTINCT from offer + expiry — a limit-window reset never cancels an offer and cancelling + never renews the limit; every slot is an independent asynchronous + sub-state machine (canonical set: free / buy-open / + buy-filled-awaiting-collect / buy-cancelled-awaiting-collect / + ready-to-sell / sell-open / sell-filled-awaiting-collect / + sell-cancelled-awaiting-collect / ready-to-requote / closed) driven + by a global scheduler pass that never touches unrelated slots; + ge_cancel moves filled items, refunded CASH, unsold units and + proceeds to the GE collection box for a separate ge_collect; + per-slot conservation is SIDE-AWARE (buy: quoted units = + filled + still_open + cancelled_unfilled and acquired items = + filled ONLY — the unfilled/cancelled quantity is cash, never + refunded units; sell: quoted = sold + still_open + returned_unsold) + with phase gates (a full buy cannot sell before collection, a full + sell cannot close before proceeds collection); + closed has ONE outgoing transition — the archival release + ``closed -> free`` — so a completed cycle's physical GE slot + returns to free for the next flip instead of stranding forever + (governor 10471); the two-rotation reuse regression proves a slot + completes buy/collect/sell/collect, archives the first ledger, and + places a second buy in the same slot without loss, duplication, or + 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, and the + woodcutting axe/mode/fixture/wiring fixtures. + 3. Global id uniqueness across the whole codex tree. Exit codes: 0 = clean (warnings ok), 1 = validation errors. Usage: python3 tools/validate.py [--stale-days 30] [--selftest] - --selftest: run the committed regression fixtures only (no tree scan) + --selftest: run the GE floor-tax regression fixtures only (no tree scan) """ import datetime import json +import math import re import sys from pathlib import Path @@ -34,10 +81,589 @@ import yaml ROOT = Path(__file__).resolve().parent.parent SCHEMA = json.loads((ROOT / "schemas" / "entry.schema.json").read_text()) +CODEX = ROOT / "codex" 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} + +def load_entries(): + out = [] + for p in CODEX.rglob("*.yaml"): + if p.name.startswith("_"): # dataset files (e.g. _f2p-alch-catalog.yaml) + continue + out.append(p) + return sorted(out) + + +MARGIN_TAGS = {"flipping", "merchanting"} + + +def ge_floor_tax_error(rel, data): + """GE floor-tax gate for a single entry. + + Applies ONLY to the unambiguous margin tags (flipping/merchanting). + The generic `ge` tag is not proof that income_per_cycle is a taxed + GE resale: a training/processing/procurement entry may carry `ge` + while modeling unrelated income/cost, so it must not be gated by + this rule. Returns an error message, or None when the entry is not + gated or its floored-tax arithmetic is correct. + + Wiki: "The 2% tax rounds down to the nearest whole number, so items + sold for below 50 coins have a tax of less than one coin" — per-item + net MUST use the floored tax, never 0.98*sell - buy. The fee is also + capped at 5,000,000 GP per item (per-item cap, documented in the + flip-candidates best_practice/risk), so tax = min(floor(0.02*sell), + 5_000_000). Owner decision 2026-08-28: one global formula for all + entries (option A), no per-entry tax-model discriminator. + """ + tags = {str(t).lower() for t in data.get("tags", [])} + if not tags & MARGIN_TAGS: + return None + eco = data.get("economy") or {} + if not all(k in eco for k in ("income_per_cycle", "cost_per_cycle", "net_per_cycle")): + return None + sell, buy = eco["income_per_cycle"], eco["cost_per_cycle"] + tax = min(math.floor(sell * 0.02), 5_000_000) + expected = sell - tax - buy + if eco["net_per_cycle"] != expected: + return ( + f"{rel}: net_per_cycle {eco['net_per_cycle']} != capped floored-tax " + f"model {expected} (sell {sell} - min(floor({sell}*0.02), 5_000_000)" + f"={tax} - buy {buy}); " + f"GE tax rounds down per item and is capped at 5,000,000 GP per item " + f"— fractional 0.98*sell - buy and uncapped tax are invalid") + return None + + +def alch_worked_example_regression(rel, data, raw_text): + """One-cast model worked-example regression (owner guidance 2026-08-28, + PR #8 repair, merged via origin/main into issue-1): entries with the + owner-selected one-cast iteration model must pin a worked example with + MONOTONIC, INDEPENDENT counters — breaks every 550 casts (1,100, 1,650, + ...) regardless of restocks, and restocks every 901 casts (1,802, ...), + so restocking never resets or shifts the break counter. Scoped to + entries that declare the one-cast model AND carry a worked example, so + unrelated entries are untouched. + """ + ruco = data.get("run_control") or {} + if "one cast" not in str(ruco.get("iteration", "")): + return [] + if "worked example" not in raw_text.lower(): + return [] + errs = [] + if "1,452" in raw_text: + errs.append(f"{rel}: worked example resets the break interval at a restock " + f"(cast 1,452 = 901 + restarted interval) — breaks must stay " + f"monotonic at 550/1,100/1,650 independently of restocks") + for marker, label in (("550", "break #1 (cast 550)"), + ("901", "restock #1 (cast 901)"), + ("1,100", "break #2 (cast 1,100)"), + ("1,650", "break #3 (cast 1,650)"), + ("1,802", "restock #2 (cast 1,802)")): + if marker not in raw_text: + errs.append(f"{rel}: worked example missing monotonic counter pin " + f"{label} — breaks/restocks must be pinned literally") + return errs + + +def _loss_reserve_stop(initial_capital, total_assets, reserve): + """Loss-reserve stop comparator in the pinned direction (owner guidance + 2026-08-29, governor review 10361, PR #8): stop fires only when the + loss EXCEEDS the reserve, i.e. when (initial capital - total assets) + > reserve. A loss makes the LHS positive; a profit (total assets > + initial capital) makes it negative and can never fire this stop. The + reversed 'total assets - initial capital' form is invalid because it + is negative exactly when a loss exists. + """ + return (initial_capital - total_assets) > reserve + + +def alch_accounting_regression(rel, data, raw_text): + """Loss-reserve + fill-price accounting regression (owner guidance + 2026-08-29 for governor review 10361, PR #8): the low-alch-members + entry must express the loss-reserve stop in the correct direction + EVERYWHERE ('initial capital - total assets > 162,990'), the reversed + sign form must never reappear, and the realized GP/hour bounds must be + derived from the declared fill-price scenarios (A live-low -3.00/cast + vs B max allowed +5% fill -30.15/cast), exposing the +5% fill loss + instead of recycling the live-low margin. Includes below/at/above + boundary fixtures and a profit-side negative regression for the + comparator itself. Scoped to the members low-alchemy entry only. + """ + if data.get("id") != "low-alch-members": + return [] + errs = [] + + # 1. comparator boundary fixtures (pure function, entry's own constants) + INIT, RESERVE = 1_308_290, 162_990 # entry capital / 6-batch reserve + fixtures = [ + ("below (loss 162,989)", INIT - 162_989, False), + ("at boundary (loss 162,990, strict >)", INIT - 162_990, False), + ("above (loss 162,991)", INIT - 162_991, True), + ("profit side (assets 50,000 above initial)", INIT + 50_000, False), + ("breakeven (assets == initial)", INIT, False), + ] + for label, total_assets, expect in fixtures: + got = _loss_reserve_stop(INIT, total_assets, RESERVE) + if got != expect: + errs.append(f"{rel}: loss-reserve comparator fixture '{label}' " + f"returned {got}, expected {expect}") + + # 2. entry direction: correct form present, reversed form absent + if "initial capital - total assets > 162,990" not in raw_text: + errs.append(f"{rel}: loss-reserve stop must be expressed as " + f"'initial capital - total assets > 162,990' everywhere") + if "total assets - initial capital" in raw_text: + errs.append(f"{rel}: loss-reserve stop uses the reversed sign form — " + f"on a loss it is negative and can never exceed the " + f"reserve; use 'initial capital - total assets > 162,990'") + + # 3. realized GP bounds must come from the fill-price scenarios + eco = data.get("economy", {}) + if eco.get("gp_per_hour_low") != -29463: + errs.append(f"{rel}: gp_per_hour_low must be -29,463 (max-fill " + f"scenario B -30.15/cast x session-fast 977.2 casts/hr " + f"= -29,462.6), got {eco.get('gp_per_hour_low')}") + if eco.get("gp_per_hour_high") != -1758: + errs.append(f"{rel}: gp_per_hour_high must be -1,758 (live-low " + f"scenario A -3.00/cast x session-slow 585.9 casts/hr " + f"= -1,757.7), got {eco.get('gp_per_hour_high')}") + for pin, label in (("-30.15", "max-fill per-cast loss -30.15"), + ("570.15", "max-fill per-cast cost 570.15"), + ("423.15", "max-fill candidate price 423.15")): + if pin not in raw_text: + errs.append(f"{rel}: economy FILL-PRICE SCENARIOS missing {label} " + f"({pin}) — the +5% accepted-fill scenario must be " + f"declared so the realized GP bounds cannot silently " + f"revert to the live-low-only margin") + return errs + + +# --- GE-flip lifecycle gates (owner guidance 2026-08-28, governor 10323 + 10390) --- +# Canonical per-slot sub-states: each GE slot is an INDEPENDENT asynchronous +# machine driven by a global scheduler pass; a pass never closes or advances +# a slot it did not touch. A COMPLETED offer leaves its assets in the GE +# collection box until ge_collect: fully filled buys and sells each get an +# explicit *-filled-awaiting-collect state (governor 10390 finding 2). +SLOT_SUB_STATES = [ + "free", # no offer, nothing held + "buy-open", # resting buy offer (partial fills keep resting) + "buy-filled-awaiting-collect", # buy FULLY filled — units sit in the GE collection box until ge_collect + "buy-cancelled-awaiting-collect", # aborted buy: bought units + refunded CASH in the box + "ready-to-sell", # units in hand (ge_collect'ed), sell edge positive + "sell-open", # resting sell offer (partial sells tracked) + "sell-filled-awaiting-collect", # sell FULLY filled — proceeds sit in the box until ge_collect + "sell-cancelled-awaiting-collect", # aborted sell: proceeds + unsold units in the box + "ready-to-requote", # collected units/refunds; requote or abandon + "closed", # completed cycle: ledger reconciled + ARCHIVED; the physical slot is released to free via the closed -> free archival transition (governor 10471) +] +# Dispatch table: the pure action a slot in a given sub-state needs this pass. +SLOT_DISPATCH = { + "free": "quote", + "buy-open": None, # resting; fill/abort decided by policies + "buy-filled-awaiting-collect": "collect", + "buy-cancelled-awaiting-collect": "collect", + "ready-to-sell": "sell", + "sell-open": None, # resting; fill/abort decided by policies + "sell-filled-awaiting-collect": "collect", + "sell-cancelled-awaiting-collect": "collect", + "ready-to-requote": "requote", + "closed": "release", # archival release: closed -> free (governor 10471) +} + + +def scheduler_dispatch(sub_state): + """The pending action for one slot in one scheduler pass, or None. + + A None (resting) slot is left completely untouched — the pass must not + close, advance, or mutate it. This is the property the worked-example + regression proves. A 'closed' slot is NOT resting: its completed + cycle's ledger was archived at ge_collect time and the pass releases + the physical slot to free ('release' = the closed -> free archival + transition), so the slot can run another flip instead of stranding + forever (governor 10471). Resting states are buy-open / sell-open + only. + """ + return SLOT_DISPATCH.get(sub_state) + + +# Side-aware GE bucket names (governor 10390 finding 4). +# BUY: the player commits CASH; acquired items are `filled` ONLY — the +# unfilled/cancelled quantity is an unspent-CASH refund, never "refunded +# units". SELL: the player holds units; sold units yield proceeds. +BUY_UNIT_BUCKETS = ["filled", "still_open", "cancelled_unfilled"] +SELL_UNIT_BUCKETS = ["sold", "still_open", "returned_unsold"] +# Physically impossible co-occurrences per side (cancel is atomic: a live +# offer has no remainder once cancelled). +IMPOSSIBLE_UNIT_PAIRS = { + "buy": [("still_open", "cancelled_unfilled")], + "sell": [("still_open", "returned_unsold")], +} + + +def conservation_violations(record): + """Side-aware per-slot GE conservation (governor 10390 finding 4). + + record: { + 'side': 'buy'|'sell', # REQUIRED — the ledger is + 'quoted': {item: qty}, # side-aware + **unit buckets (side-specific): # {item: qty} + 'cash_quoted': int, # BUY: cash committed + 'spent'/'still_committed'/'refunded': int, # BUY cash partition + 'proceeds_in_box'/'cash_collected': int, # SELL proceeds + 'sub_state': str, # phase gate input + } + + BUY invariants: + - unit partition: quoted == filled + still_open + cancelled_unfilled + per item; + - ACQUIRED ITEMS == filled ONLY: any sell-side unit bucket (sold / + returned_unsold) or a 'refunded' unit bucket on a buy record is a + violation — the unfilled/cancelled quantity is CASH (phantom + "refunded units" guard); + - cash partition: cash_quoted == spent + still_committed + refunded + (the unspent remainder of a buy is the cash refund); + - no item may be both still_open and cancelled_unfilled (cancel is + atomic). + SELL invariants: + - unit partition: quoted == sold + still_open + returned_unsold per + item; + - no item may be both still_open and returned_unsold (cancel is + atomic); sold + still_open IS legal (partial sell rest). + Shared: no negative quantities. + """ + out = [] + side = record.get("side", "buy") + if side not in ("buy", "sell"): + out.append(f"record side {side!r} must be 'buy' or 'sell'") + return out + buckets = BUY_UNIT_BUCKETS if side == "buy" else SELL_UNIT_BUCKETS + foreign = {"filled", "still_open", "cancelled_unfilled", "sold", + "returned_unsold"} - set(buckets) + for b in sorted(foreign): + if record.get(b): + out.append( + f"{side} ledger uses {b} units — invalid for side {side}: " + f"buy-side unfilled/cancelled quantity is CASH " + f"(refunded), never {b} units; sell-side units partition " + f"into sold/still_open/returned_unsold") + if side == "buy": + # A unit map under a CASH field is the phantom-items guard: a buy's + # unfilled/cancelled quantity is an unspent-CASH refund, never + # "refunded units" (governor 10390 finding 4). + for c in ("cash_quoted", "spent", "still_committed", "refunded"): + if isinstance(record.get(c), dict): + out.append( + f"buy ledger carries {c} as units — the unfilled/" + f"cancelled quantity of a buy is CASH (refunded), " + f"never {c} units (phantom items)") + for b in buckets: + for item in record.get(b, {}): + q = record[b][item] + if q < 0: + out.append(f"item '{item}' bucket {b} is negative ({q})") + quoted = record.get("quoted", {}) + for item, qq in quoted.items(): + if qq < 0: + out.append(f"item '{item}' quoted is negative ({qq})") + total = sum(record.get(b, {}).get(item, 0) for b in buckets) + if total != qq: + out.append( + f"item '{item}' quoted {qq} != " + f"{' + '.join(buckets)} ({total})") + in_b = {b for b in buckets if item in record.get(b, {})} + for a, b in IMPOSSIBLE_UNIT_PAIRS[side]: + if (a in in_b and b in in_b + and record.get(a, {}).get(item) + and record.get(b, {}).get(item)): + out.append( + f"item '{item}' appears in both {a} and {b} — " + f"physically impossible (a cancelled offer has no " + f"live remainder)") + if side == "buy": + cash_fields = ["cash_quoted", "spent", "still_committed", "refunded"] + for c in cash_fields: + if isinstance(record.get(c), (int, float)) and record[c] < 0: + out.append(f"{c} is negative ({record[c]})") + cash_vals = {c: record.get(c) for c in cash_fields} + if all(isinstance(v, (int, float)) for v in cash_vals.values()): + if cash_vals["cash_quoted"] != ( + cash_vals["spent"] + cash_vals["still_committed"] + + cash_vals["refunded"]): + out.append( + f"buy cash quoted {cash_vals['cash_quoted']} != spent " + f"{cash_vals['spent']} + still_committed " + f"{cash_vals['still_committed']} + refunded " + f"{cash_vals['refunded']} — the unspent remainder of a " + f"buy is the CASH refund") + else: + for c in ("proceeds_in_box", "cash_collected"): + if record.get(c, 0) < 0: + out.append(f"{c} is negative ({record[c]})") + return out + + +def slot_phase_violations(record): + """Phase gates (governor 10390 finding 4): assets may not be reused or + a slot closed before the GE collection box is withdrawn. + + - 'buy-filled-awaiting-collect': filled must equal quoted (fully + filled) and NOTHING may have been sold yet — a full buy that sells + before ge_collect is a violation (owned units cannot leave the + collection box early). + - 'sell-filled-awaiting-collect': sold units require proceeds_in_box + > 0 — proceeds arrive in the box as units fill and only leave via + ge_collect. + - 'closed': nothing may remain uncollected — proceeds_in_box must be + 0 and no cancelled/returned units may still be in the box (a full + sell may not close before proceeds collection). + """ + out = [] + sub = record.get("sub_state") + side = record.get("side", "buy") + sold = sum((record.get("sold") or {}).values()) + filled = sum((record.get("filled") or {}).values()) + quoted = sum((record.get("quoted") or {}).values()) + if side == "buy" and sub == "buy-filled-awaiting-collect": + if sold: + out.append( + "buy-filled-awaiting-collect slot has sold units — a full " + "buy cannot sell before ge_collect") + if filled != quoted: + out.append( + f"buy-filled-awaiting-collect slot is not fully filled " + f"(filled {filled} != quoted {quoted})") + if sub == "sell-filled-awaiting-collect" and sold and not record.get("proceeds_in_box"): + out.append( + "sell-filled-awaiting-collect slot has sold units but no " + "proceeds_in_box — proceeds arrive in the collection box on " + "fill and leave only via ge_collect") + if sub == "closed": + if record.get("proceeds_in_box"): + out.append( + "closed slot still has proceeds_in_box — a full sell may " + "not close before proceeds collection (ge_collect first)") + cancelled = sum((record.get("cancelled_unfilled") or {}).values()) + returned = sum((record.get("returned_unsold") or {}).values()) + if cancelled or returned: + out.append( + "closed slot still holds cancelled/returned units — " + "collect them before closing the slot") + return out + + +def buy_capacity_ok(limit_used, limit, qty_requested): + """The 4h buy-limit ledger (governor 10323 finding 1): the limit resets + 4h after the FIRST unit was bought; cancelling an offer does NOT renew + it, so a requote before the reset cannot buy beyond the remaining + capacity. Returns True when limit_used + qty_requested <= limit. + """ + return limit_used + qty_requested <= limit + + +# Deterministic ledger frames for ONE physical GE slot across TWO full +# flips (governor 10471): the fixture walks a complete buy/collect/ +# sell/collect cycle, archives the first ledger at `closed`, releases the +# physical slot to `free`, and opens a SECOND buy in the SAME slot. Every +# frame must satisfy both conservation checks; the archived first-cycle +# record must survive rotation 2 untouched (no loss, duplication, or +# mutation). Prices: Rune arrowtips buy 223 / sell 244 / tax +# min(floor(244*0.02), 5M) = 4 / proceeds 2,440,000 (snapshot 2026-08-27, +# matches the worked example). +_CYCLE_ITEM = "Rune arrowtips" + +_CYCLE_FRAMES = [ + # (sub_state, ledger) — ledger is side-aware and conservation-clean. + ("free", {"side": "buy", "quoted": {}, "filled": {}, "still_open": {}, + "cancelled_unfilled": {}, "cash_quoted": 0, "spent": 0, + "still_committed": 0, "refunded": 0}), + ("buy-open", {"side": "buy", "quoted": {_CYCLE_ITEM: 10_000}, + "filled": {}, "still_open": {_CYCLE_ITEM: 10_000}, + "cancelled_unfilled": {}, "cash_quoted": 2_230_000, + "spent": 0, "still_committed": 2_230_000, "refunded": 0}), + ("buy-filled-awaiting-collect", + {"side": "buy", "quoted": {_CYCLE_ITEM: 10_000}, + "filled": {_CYCLE_ITEM: 10_000}, "still_open": {}, + "cancelled_unfilled": {}, "cash_quoted": 2_230_000, + "spent": 2_230_000, "still_committed": 0, "refunded": 0}), + ("ready-to-sell", + {"side": "buy", "quoted": {_CYCLE_ITEM: 10_000}, + "filled": {_CYCLE_ITEM: 10_000}, "still_open": {}, + "cancelled_unfilled": {}, "cash_quoted": 2_230_000, + "spent": 2_230_000, "still_committed": 0, "refunded": 0}), + ("sell-open", {"side": "sell", "quoted": {_CYCLE_ITEM: 10_000}, + "sold": {}, "still_open": {_CYCLE_ITEM: 10_000}, + "returned_unsold": {}, "proceeds_in_box": 0, + "cash_collected": 0}), + ("sell-filled-awaiting-collect", + {"side": "sell", "quoted": {_CYCLE_ITEM: 10_000}, + "sold": {_CYCLE_ITEM: 10_000}, "still_open": {}, + "returned_unsold": {}, "proceeds_in_box": 2_440_000, + "cash_collected": 0}), + ("closed", {"side": "sell", "quoted": {_CYCLE_ITEM: 10_000}, + "sold": {_CYCLE_ITEM: 10_000}, "still_open": {}, + "returned_unsold": {}, "proceeds_in_box": 0, + "cash_collected": 2_440_000}), # ARCHIVED here + ("free", {"side": "buy", "quoted": {}, "filled": {}, "still_open": {}, + "cancelled_unfilled": {}, "cash_quoted": 0, "spent": 0, + "still_committed": 0, "refunded": 0}), # released: physical slot reusable + # rotation 2: the SAME physical slot opens a second buy (ledger reset; + # the archived rotation-1 record above must remain untouched). + ("buy-open", {"side": "buy", "quoted": {_CYCLE_ITEM: 10_000}, + "filled": {}, "still_open": {_CYCLE_ITEM: 10_000}, + "cancelled_unfilled": {}, "cash_quoted": 2_230_000, + "spent": 0, "still_committed": 2_230_000, "refunded": 0}), +] + +# The canonical complete flip cycle: rotation 1 (buy/collect/sell/collect) +# plus the archival release and the start of rotation 2. +_CYCLE_PATH = [f[0] for f in _CYCLE_FRAMES] + +# Index of the `closed` frame (archived ledger) and the frames around it. +_CLOSED_IDX = _CYCLE_PATH.index("closed") + + +def slot_reuse_violations(transitions): + """Two-rotation slot-reuse regression (governor 10471). + + Walks ``_CYCLE_PATH`` through the entry's ``slot_transitions`` and + validates every ledger frame against both conservation gates. A + ``closed`` state with no outgoing transition (or one that does not + release to ``free``) strands the physical GE slot after one flip — + the exact defect this regression exists to forbid. Errors are + returned as a list of strings. + """ + out = [] + for a, b in zip(_CYCLE_PATH, _CYCLE_PATH[1:]): + if b not in (transitions.get(a) or []): + out.append( + f"slot cycle: transition {a} -> {b} is missing — a completed " + f"flip must end by releasing the physical slot to free " + f"(archival release closed -> free), otherwise the slot " + f"performs one flip and then becomes permanently unusable") + for state, record in _CYCLE_FRAMES: + rec = dict(record, sub_state=state) + con = conservation_violations(rec) + if con: + out.append(f"slot cycle: conservation fails at '{state}': {con}") + ph = slot_phase_violations(rec) + if ph: + out.append(f"slot cycle: phase gate fails at '{state}': {ph}") + return out + + +def archived_ledger_preserved_after_second_buy(): + """The rotation-1 archived ledger survives rotation 2 intact. + + ``closed`` archives a deep copy of the completed cycle's ledger; when + the same physical slot is released to free and opens a second buy, the + archived record must be bit-for-bit unchanged (no loss, duplication, + or mutation) while the live ledger is a fresh empty record. + """ + archived = json.loads(json.dumps(_CYCLE_FRAMES[_CLOSED_IDX][1])) + archived_state = _CYCLE_FRAMES[_CLOSED_IDX][0] + live = {k: (dict(v) if isinstance(v, dict) else v) + for k, v in _CYCLE_FRAMES[-1][1].items()} # rotation-2 buy-open ledger + if archived != _CYCLE_FRAMES[_CLOSED_IDX][1]: + return ["slot cycle: archived rotation-1 ledger was MUTATED by rotation 2"] + if archived_state != "closed": + return [f"slot cycle: unexpected archived state {archived_state!r}"] + # The live ledger at rotation-2 buy-open must be a fresh record, not a + # copy of the archived one: quoted cash/units belong to rotation 2. + if live.get("cash_quoted") != 2_230_000 or live.get("proceeds_in_box"): + return ["slot cycle: rotation-2 live ledger is not a fresh record"] + return [] + + +def ge_lifecycle_errors(rel, data): + """GE-flip lifecycle gates (flipping/merchanting entries only): + + - the 4h buy-limit ledger is distinct from offer expiry: the entry must + declare a limit_ledger + an independent quote_age_policy; + - the slot model is per-slot asynchronous: slot_state_machine must + declare exactly the canonical sub-states, valid transitions, a + scheduler_iteration, persisted_state, conservation and break_resume; + 'closed' is NOT a sink: it must release the physical slot via the + archival transition closed -> free, and the committed two-rotation + reuse regression (slot_reuse_violations) must pass; + - rates: ceiling_gp_per_hour is the typed economic ceiling and + 0 <= gp_per_hour_low <= gp_per_hour_high <= ceiling. + """ + errors = [] + tags = {str(t).lower() for t in data.get("tags", [])} + if not tags & MARGIN_TAGS: + return errors + eco = data.get("economy") or {} + # --- typed ceiling vs realized bounds --- + ceil = eco.get("ceiling_gp_per_hour") + if not isinstance(ceil, (int, float)) or ceil <= 0: + errors.append(f"{rel}: flipping entries must declare " + f"economy.ceiling_gp_per_hour (the typed 4h buy-limit " + f"economic ceiling, distinct from realized rates)") + return errors + low, high = eco.get("gp_per_hour_low"), eco.get("gp_per_hour_high") + for name, val in (("gp_per_hour_low", low), ("gp_per_hour_high", high)): + if not isinstance(val, (int, float)) or val < 0: + errors.append(f"{rel}: {name} must be a non-negative realized " + f"scenario bound") + if isinstance(low, (int, float)) and isinstance(high, (int, float)) \ + and isinstance(ceil, (int, float)): + if not (low <= high <= ceil): + errors.append( + f"{rel}: realized bounds must nest under the ceiling: " + f"0 <= gp_per_hour_low {low} <= gp_per_hour_high {high} " + f"<= ceiling_gp_per_hour {ceil}") + # --- per-slot asynchronous model --- + ssm = data.get("slot_state_machine") or {} + if not isinstance(ssm, dict): + errors.append(f"{rel}: flipping entries must declare a " + f"slot_state_machine block (per-slot asynchronous sub-states)") + return errors + declared = [str(s) for s in ssm.get("sub_states", [])] + if sorted(declared) != sorted(SLOT_SUB_STATES): + errors.append( + f"{rel}: slot_state_machine.sub_states must be exactly the canonical " + f"set: {', '.join(SLOT_SUB_STATES)} (got: {', '.join(declared) or 'none'})") + trans = ssm.get("slot_transitions") or {} + if not isinstance(trans, dict): + errors.append(f"{rel}: slot_state_machine.slot_transitions must be a mapping") + trans = {} + for from_s, tos in trans.items(): + if from_s not in SLOT_SUB_STATES: + errors.append(f"{rel}: slot transition from unknown sub-state '{from_s}'") + for to_s in tos if isinstance(tos, list) else []: + if to_s not in SLOT_SUB_STATES: + errors.append(f"{rel}: slot transition '{from_s}' -> unknown " + f"sub-state '{to_s}'") + for state in SLOT_SUB_STATES: + if not trans.get(state): + errors.append(f"{rel}: slot sub-state '{state}' has no transitions " + f"— every state must be able to advance; a closed " + f"slot must release the physical slot via the " + f"archival closed -> free transition (governor 10471)") + if list(trans.get("closed") or []) != ["free"]: + errors.append(f"{rel}: 'closed' must have exactly the archival release " + f"['free'] — a closed slot with no outgoing transition " + f"performs one flip and then becomes permanently " + f"unusable (governor 10471)") + whens = [str(w) for w in ssm.get("slot_transition_whens", []) or []] + for w in whens: + arrow = w.split("->") + if len(arrow) != 2 or arrow[0].strip() not in SLOT_SUB_STATES \ + or arrow[1].strip().split(":")[0].strip() not in SLOT_SUB_STATES: + errors.append(f"{rel}: slot_transition_whens entry '{w}' must be " + f"'from -> to: condition' with canonical sub-states") + for field in ("scheduler_iteration", "quote_age_policy", "persisted_state", + "limit_ledger", "conservation", "break_resume"): + if not str(ssm.get(field, "")).strip(): + errors.append(f"{rel}: slot_state_machine.{field} must be declared") + # Two-rotation reuse regression (governor 10471): every flipping entry's + # encoded machine must complete buy/collect/sell/collect, release the + # physical slot to free, and support a second buy in the same slot. + errors += [f"{rel}: {e}" for e in slot_reuse_violations(trans)] + return errors + + +# --- 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 @@ -57,10 +683,10 @@ AXE_TIERS = [ ("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} +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): @@ -76,41 +702,84 @@ def best_axe(wc_level, attack_level): 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})") +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 10323 pattern: no phrase-search, exact computed checks). + """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, 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. + 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", [])} @@ -130,7 +799,7 @@ def woodcutting_errors(rel, data): f"got {sorted(declared)}") return errors - # --- recompute the committed examples --- + # --- 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") @@ -173,6 +842,15 @@ def woodcutting_errors(rel, data): 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 " @@ -188,53 +866,152 @@ def woodcutting_errors(rel, data): 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) + # --- canonical TOP-ROW `rates` reference (ladder[0], 28 slots equipped) --- 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)") + 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 - 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 + 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 - computed) / computed > 0.01: + 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 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: + 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 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%") + 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_selftest(verbose=False): - """Committed regression fixtures. Returns error strings.""" + """Regression fixtures for the GE floor-tax + lifecycle gates; returns + error strings.""" errors = [] def check(name, passed): @@ -243,7 +1020,237 @@ def run_selftest(verbose=False): if not passed: errors.append(f"selftest: {name}") - # --- owner-mandated axe coverage (10288): equip vs carry --- + fixtures = [ + # name, tags, economy, expect_error + ("fractional-tax flipping entry", ["flipping"], + {"income_per_cycle": 244, "cost_per_cycle": 223, "net_per_cycle": 16.12}, True), + # 5000 - floor(5000*0.02) - 3000 = 1900 != 2000: this fixture's + # economics deliberately do NOT satisfy the flip equation, so the + # old `ge`-tag scoping would false-positive on it (2000 != 1900). + ("non-margin entry with only 'ge' + 'processing'", ["ge", "processing"], + {"income_per_cycle": 5000, "cost_per_cycle": 3000, "net_per_cycle": 2000}, False), + ("correctly-floored flipping entry", ["flipping", "merchanting"], + {"income_per_cycle": 244, "cost_per_cycle": 223, "net_per_cycle": 17}, False), + # sell 300,000,000 -> uncapped floor tax is 6,000,000; the per-item + # cap makes it 5,000,000, so the correct net is 1,000,000. The old + # uncapped rule demanded net=0 and rejected this valid entry. + ("high-value flip above the 5,000,000 GP per-item tax cap", + ["flipping"], + {"income_per_cycle": 300_000_000, "cost_per_cycle": 294_000_000, + "net_per_cycle": 1_000_000}, False), + ] + for name, tags, eco, expect_error in fixtures: + rel = f"selftest/{name}" + got_error = ge_floor_tax_error(rel, {"tags": tags, "economy": eco}) is not None + passed = got_error == expect_error + if verbose: + print(f"SELFTEST {'PASS' if passed else 'FAIL'} {name}") + if not passed: + errors.append( + f"selftest: '{name}' expected " + f"{'an error' if expect_error else 'no error'} but got " + f"{'an error' if got_error else 'no error'}") + + # --- per-slot scheduler dispatch: the worked example as a regression --- + # Governor 10323 finding 3 + owner: reproduce the simultaneous + # partial-buy / full-buy-fill / no-fill-buy case and prove no + # unrelated slot is closed or advanced. Under governor 10390 finding 2 + # a FULL buy fill goes to buy-filled-awaiting-collect (assets sit in + # the GE collection box) — the pass dispatches ge_collect, it never + # lets the slot sell directly. + slot_states = {"arrowtips": "buy-open", # partial fill 4,000/10,000 — resting + "iron": "buy-filled-awaiting-collect", # full fill — ge_collect this pass + "cosmic": "buy-open"} # no fill, edge positive — resting + dispatched = {name: scheduler_dispatch(s) for name, s in slot_states.items()} + ok = dispatched == {"arrowtips": None, "iron": "collect", "cosmic": None} + check("worked example: partial-buy + full-buy-fill + no-fill slots dispatch only the iron slot to ge_collect; unrelated slots stay untouched", + ok) + check("resting slots (buy-open) are never closed or advanced by a pass", + dispatched.get("arrowtips") is None and dispatched.get("cosmic") is None) + check("a fully filled buy must pass through buy-filled-awaiting-collect (ge_collect) before any sell", + scheduler_dispatch("buy-filled-awaiting-collect") == "collect" + and scheduler_dispatch("sell-filled-awaiting-collect") == "collect" + and scheduler_dispatch("ready-to-sell") == "sell") + + # --- side-aware conservation invariants (finding 4) --- + # BUY balanced snapshot mirroring the worked example t1: 10,000 Rune + # arrowtips quoted @ 223; 4,000 filled (units in the GE collection + # box), 6,000 still open on the resting offer; cash 2,230,000 = + # spent 892,000 + still_committed 1,338,000 + refunded 0. + buy_partial = { + "side": "buy", + "quoted": {"Rune arrowtips": 10_000}, + "filled": {"Rune arrowtips": 4_000}, + "still_open": {"Rune arrowtips": 6_000}, + "cancelled_unfilled": {}, + "cash_quoted": 2_230_000, "spent": 892_000, + "still_committed": 1_338_000, "refunded": 0, + } + check("conservation: BUY side — partial fill splits units into filled + still_open and cash into spent + still_committed (partition sums hold, zero violations)", + conservation_violations(buy_partial) == []) + buy_cancelled = dict(buy_partial, still_open={}, + cancelled_unfilled={"Rune arrowtips": 6_000}, + still_committed=0, refunded=1_338_000) + check("conservation: BUY side — a cancelled partial buy carries filled units + refunded CASH (still_open -> cancelled_unfilled + refunded), zero violations", + conservation_violations(buy_cancelled) == []) + phantom_rec = dict(buy_cancelled, refunded={"Rune arrowtips": 6_000}) + check("conservation: BUY side — 'refunded UNITS' on a buy is a phantom (unfilled/cancelled quantity is CASH): violation", + any("CASH" in v or "invalid for side" in v + for v in conservation_violations(phantom_rec))) + sold_on_buy = dict(buy_partial, sold={"Rune arrowtips": 4_000}) + check("conservation: BUY side — a full buy that sells before ge_collect (sold units on a buy ledger) is a violation", + any("invalid for side" in v for v in conservation_violations(sold_on_buy))) + buy_cash_bad = dict(buy_partial, still_committed=1_380_000) + check("conservation: BUY side — cash partition mismatch (spent + still_committed + refunded != cash_quoted) is a violation", + any("cash quoted" in v for v in conservation_violations(buy_cash_bad))) + # SELL balanced: 10,000 sold @ 157, proceeds 1,570,000 in the box. + sell_full = { + "side": "sell", + "quoted": {"Iron bar": 10_000}, + "sold": {"Iron bar": 10_000}, + "still_open": {}, "returned_unsold": {}, + "proceeds_in_box": 1_570_000, "cash_collected": 0, + } + check("conservation: SELL side — full sell partitions sold + still_open + returned_unsold, proceeds in the box (zero violations)", + conservation_violations(sell_full) == []) + sell_partial = dict(sell_full, sold={"Iron bar": 4_000}, + still_open={"Iron bar": 6_000}, + proceeds_in_box=628_000) + check("conservation: SELL side — a partial sell legitimately spans sold + still_open (zero violations)", + conservation_violations(sell_partial) == []) + sell_dup = dict(sell_full, sold={"Iron bar": 6_000}, + still_open={"Iron bar": 3_000}, + returned_unsold={"Iron bar": 1_000}) + check("conservation: SELL side — units both in a live offer AND returned (impossible pair) is a violation", + any("both still_open and returned_unsold" in v + for v in conservation_violations(sell_dup))) + over_rec = dict(buy_partial, filled={"Rune arrowtips": 7_000}) + check("conservation: over-reported buckets break the quoted partition (sum 13,000 != quoted 10,000)", + any("quoted 10000 !=" in v for v in conservation_violations(over_rec))) + neg_rec = dict(sell_full, sold={"Iron bar": -1}) + check("conservation: a negative bucket is a violation", + any("negative" in v for v in conservation_violations(neg_rec))) + + # --- phase gates (finding 4: assets cannot be reused/closed pre-collect) --- + check("phase gate: a full buy that sells before ge_collect (buy-filled-awaiting-collect with sold units) is rejected", + any("cannot sell before ge_collect" in v + for v in slot_phase_violations(dict(buy_partial, sub_state="buy-filled-awaiting-collect", + filled={"Rune arrowtips": 10_000}, still_open={}, + sold={"Rune arrowtips": 10_000})))) + check("phase gate: a full sell that closes before proceeds collection (closed with proceeds_in_box > 0) is rejected", + any("before proceeds collection" in v + for v in slot_phase_violations(dict(sell_full, sub_state="closed")))) + check("phase gate: a sell-filled-awaiting-collect slot with sold units but no proceeds_in_box is rejected", + any("no proceeds_in_box" in v + for v in slot_phase_violations(dict(sell_full, sub_state="sell-filled-awaiting-collect", + proceeds_in_box=0)))) + check("phase gate: a closed slot that has been fully collected (no proceeds_in_box, no cancelled/returned units) passes", + slot_phase_violations(dict(sell_full, sub_state="closed", + proceeds_in_box=0, cash_collected=1_570_000)) == []) + + # --- 4h buy-limit ledger vs offer expiry (finding 1) --- + check("buy limit: a requote within the remaining capacity is allowed (4,000 used of 10,000 -> 6,000)", + buy_capacity_ok(limit_used=4000, limit=10000, qty_requested=6000)) + check("buy limit: a requote beyond the remaining capacity is blocked (6,001 > 6,000 remaining)", + not buy_capacity_ok(limit_used=4000, limit=10000, qty_requested=6001)) + check("buy limit: a cancelled offer does NOT renew the limit (10,000 used of 10,000 -> any requote blocked before reset)", + not buy_capacity_ok(limit_used=10000, limit=10000, qty_requested=1)) + + # --- structured lifecycle gates (findings 1-5 + governor 10390) --- + margin_tags = ["flipping"] + ssm_ok = { + "sub_states": SLOT_SUB_STATES, + "slot_transitions": { + "free": ["buy-open"], + "buy-open": ["buy-filled-awaiting-collect", + "buy-cancelled-awaiting-collect"], + "buy-filled-awaiting-collect": ["ready-to-sell", "free"], + "buy-cancelled-awaiting-collect": ["ready-to-sell", "ready-to-requote", "free"], + "ready-to-sell": ["sell-open"], + "sell-open": ["sell-filled-awaiting-collect", + "sell-cancelled-awaiting-collect"], + "sell-filled-awaiting-collect": ["closed"], + "sell-cancelled-awaiting-collect": ["ready-to-requote", "closed"], + "ready-to-requote": ["buy-open", "sell-open", "free"], + "closed": ["free"], # archival release: physical slot reusable (governor 10471) + }, + "slot_transition_whens": [ + "free -> buy-open: quote placed", + "buy-open -> buy-filled-awaiting-collect: offer FULLY filled — units sit in the GE collection box until ge_collect", + "buy-open -> buy-cancelled-awaiting-collect: abort on stale edge (margin no longer positive) or strategy reallocation — never on the 4h limit reset", + "buy-filled-awaiting-collect -> ready-to-sell: ge_collect withdrawn units; sell edge positive", + "buy-filled-awaiting-collect -> free: ge_collect withdrawn; margin gone, slot released", + "buy-cancelled-awaiting-collect -> ready-to-sell: collected, sell edge positive", + "buy-cancelled-awaiting-collect -> ready-to-requote: collected; margin positive and capacity remains", + "buy-cancelled-awaiting-collect -> free: collected, full cash refund, slot freed", + "ready-to-sell -> sell-open: sell offer placed", + "sell-open -> sell-filled-awaiting-collect: sell FULLY filled — proceeds sit in the box until ge_collect", + "sell-open -> sell-cancelled-awaiting-collect: abort on stale edge or strategy reallocation", + "sell-filled-awaiting-collect -> closed: proceeds collected and ledger reconciled + ARCHIVED", + "sell-cancelled-awaiting-collect -> ready-to-requote: collected, requote", + "sell-cancelled-awaiting-collect -> closed: collected, ledger archived, slot closed (exit taken)", + "closed -> free: archival release — completed cycle's ledger stays archived; the physical GE slot is reusable for the next flip", + "ready-to-requote -> buy-open: requoted buy", + "ready-to-requote -> sell-open: requoted sell", + "ready-to-requote -> free: abandoned", + ], + "scheduler_iteration": "one pass over all slots; dispatch pending actions only", + "quote_age_policy": "offers rest while the scanned margin stays positive; aborted only on a stale edge or strategy reallocation — the 4h limit reset never cancels an offer", + "persisted_state": "per-slot record: sub-state + side-aware quoted/filled/sold/collected buckets", + "limit_ledger": "limit_used + first_fill_ts + limit_resets_at, independent of offer expiry; cancellation never renews it", + "conservation": "side-aware: buy quoted units = filled + still_open + cancelled_unfilled and acquired items = filled only; sell quoted = sold + still_open + returned_unsold", + "break_resume": "breaks between passes; state persists in the per-slot record", + } + flip_ok = {"tags": margin_tags, "economy": { + "income_per_cycle": 244, "cost_per_cycle": 223, "net_per_cycle": 17, + "ceiling_gp_per_hour": 149500, "gp_per_hour_low": 102600, + "gp_per_hour_high": 138800}, "slot_state_machine": ssm_ok} + check("flip lifecycle: ceiling + nested realized bounds + canonical 10-state slot machine passes", + ge_lifecycle_errors("ok.yaml", flip_ok) == []) + errs = ge_lifecycle_errors("bad.yaml", {"tags": margin_tags, "economy": { + "income_per_cycle": 244, "cost_per_cycle": 223, "net_per_cycle": 17, + "ceiling_gp_per_hour": 149500, "gp_per_hour_low": 160000, + "gp_per_hour_high": 138800}}) + check("flip lifecycle: realized low above the ceiling is rejected (bounds must nest)", + any("nest" in e for e in errs)) + errs = ge_lifecycle_errors("bad2.yaml", {"tags": margin_tags, "economy": { + "income_per_cycle": 244, "cost_per_cycle": 223, "net_per_cycle": 17, + "gp_per_hour_low": 0, "gp_per_hour_high": 138800}}) + check("flip lifecycle: missing typed ceiling field is rejected", + any("ceiling_gp_per_hour" in e for e in errs)) + errs = ge_lifecycle_errors("bad3.yaml", dict(flip_ok, slot_state_machine=dict( + ssm_ok, sub_states=["free", "buy-open"]))) + check("flip lifecycle: non-canonical sub-state set is rejected", + any("canonical" in e for e in errs)) + errs = ge_lifecycle_errors("bad4.yaml", dict(flip_ok, slot_state_machine=dict( + ssm_ok, slot_transitions={"free": ["buy-open"], + "buy-open": ["ready-to-sell", + "buy-cancelled-awaiting-collect"]}, + slot_transition_whens=ssm_ok["slot_transition_whens"][:4]))) + check("flip lifecycle: a sub-state without transitions (ready-to-sell missing) is rejected", + any("no transitions" in e for e in errs)) + errs = ge_lifecycle_errors("bad5.yaml", dict(flip_ok, slot_state_machine=dict( + ssm_ok, slot_transitions=dict(ssm_ok["slot_transitions"], closed=[])))) + check("flip lifecycle: a 'closed' sink (no archival release) is rejected — the physical slot would perform one flip and strand", + any("archival release" in e for e in errs)) + + # --- slot reuse through rotations (governor 10471) --- + check("slot cycle: the archival release closed -> free is a dispatched scheduler action", + scheduler_dispatch("closed") == "release") + check("slot reuse: one full buy/collect/sell/collect lifecycle completes and the SAME physical slot opens a second buy (two-rotation walk passes)", + slot_reuse_violations(ssm_ok["slot_transitions"]) == []) + check("slot reuse: the rotation-1 archived ledger survives rotation 2 intact (no loss, duplication, or mutation)", + archived_ledger_preserved_after_second_buy() == []) + sink_trans = dict(ssm_ok["slot_transitions"], closed=[]) + check("slot reuse: a machine whose closed has no outgoing transition FAILS the two-rotation regression (one flip, then permanently unusable)", + any("permanently unusable" in v + for v in slot_reuse_violations(sink_trans))) + closed_no_free = dict(ssm_ok["slot_transitions"], closed=["ready-to-sell"]) + check("slot reuse: a closed that does not release to free FAILS the two-rotation regression", + any("closed -> free" in v + for v in slot_reuse_violations(closed_no_free))) + + # --- 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", @@ -256,35 +1263,61 @@ def run_selftest(verbose=False): 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): + 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"}, + "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"}, + "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"}, + "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"}], - "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}, + "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"}}, @@ -292,100 +1325,130 @@ def run_selftest(verbose=False): "inventory_policy": "drop", "ladder": [ {"tree": "maple", "min_level": 45, "gate": "dragon-slayer-i", - "spot": "corsair cove resource area", "bank": "none (drop)"}, + "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": "draynor"}, + "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": "draynor"}, + "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": "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}, + "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": 4632, "log_price": 12, + "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"}, + "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"}, + "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"}, + "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"}], - "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}, + "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 passes every structured gate", - woodcutting_errors("selftest/good", fake_entry()) == []) - bad = fake_entry(woodcutting_modes=fake_entry()["woodcutting_modes"][1:]) + 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/missing", bad))) - bad = fake_entry(axe_examples=[{"account": "41WC/1Atk", "woodcutting_level": 41, "attack_level": 1, - "expected_axe": "adamant", "expected_equipped": True}]) + 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/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"]]) + 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/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"]]) + 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/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"]]) + 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/order", bad))) - # rates: 9999 logs/hr cannot match the model chain (min cycle ~140s -> <=720/hr) + 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 fake_entry()["woodcutting_modes"]] - bad = fake_entry(woodcutting_modes=badm) + for m in wc_fake_entry()["woodcutting_modes"]] 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"]) + 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/axes", bad))) - # xp mismatch: 622 logs of willow = 41,985; declaring 40,000 must fail - badm = fake_entry()["woodcutting_modes"] + 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)) - 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))) + 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 load_entries(): - out = [] - for p in sorted((ROOT / "codex").rglob("*.yaml")): - if p.name.startswith("_"): - continue # dataset files (e.g. _f2p-alch-catalog.yaml) are not entries - out.append(p) - return out - - def main(): global STALE_DAYS if "--stale-days" in sys.argv: STALE_DAYS = int(sys.argv[sys.argv.index("--stale-days") + 1]) - if "--selftest" in sys.argv: - errs = run_selftest(verbose=True) - for e in errs: - print(f"SELFTEST FAIL {e}") - print(f"RESULT: selftest, {len(errs)} errors, 0 warnings") - return 1 if errs else 0 errors, warnings = [], [] + if "--selftest" in sys.argv: + errors += run_selftest(verbose=True) + print(f"RESULT: selftest, {len(errors)} errors, 0 warnings") + return 1 if errors else 0 + + # The fix itself is gated: regression fixtures run on every invocation. + errors += run_selftest(verbose=False) entries = load_entries() if not entries: print("ERROR: no codex entries found under codex/") @@ -438,6 +1501,15 @@ def main(): if term not in declared: errors.append(f"{rel}: terminal '{term}' not declared") + # one-cast worked-example regression (owner guidance 2026-08-28, + # PR #8): monotonic break/restock counters, scoped to the one-cast model + errors += alch_worked_example_regression(rel, data, path.read_text()) + + # loss-reserve direction + fill-price GP bound regression (owner + # guidance 2026-08-29 for governor 10361, PR #8), scoped to + # low-alch-members + errors += alch_accounting_regression(rel, data, path.read_text()) + # action id/step uniqueness a_ids = [a["id"] for a in data["actions"]] a_steps = [a["step"] for a in data["actions"]] @@ -446,10 +1518,19 @@ def main(): if len(a_steps) != len(set(a_steps)): errors.append(f"{rel}: duplicate action step names") - # 2b. GE flip-tax and woodcutting structured gates + # GE-margin floor-tax arithmetic (see ge_floor_tax_error): scoped to + # the unambiguous margin tags flipping/merchanting. The generic `ge` + # tag alone must not gate this rule — alchemy/processing/procurement + # entries can carry `ge` with income unrelated to a taxed GE resale. err = ge_floor_tax_error(rel, data) if err: errors.append(err) + # GE-flip lifecycle gates (per-slot async model, 4h limit ledger vs + # offer expiry, typed ceiling vs realized scenario bounds). + errors += ge_lifecycle_errors(rel, data) + # 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). errors += woodcutting_errors(rel, data) # 3. staleness / research TODOs