fix(codex): pin alch cadence to delay policy, unify nature-rune limit, phase-aware SII (issue #5)

Governor-review bounce (PR #10) fixes on issue-5:

1. Timing units + rates: one OSRS tick = 0.6s so a 3-tick cast is 1.8s,
   not 5.4s as both action comments claimed. The mandatory 1.6-3.4s
   post-action delay (mid ~2.5s) allows ~1,440 casts/hr before overhead;
   rates are now pinned at 1,400 casts/hr: LA 43,400 / SII 74,200 / HLA
   91,000 xp/hr (was 55,800 / 95,400 / 117,000 @ 1,800). The old 1,800
   figures were unreachable under the entries' own pacing.

2. Nature rune limit unified to 18000 (mapping 'limit' + Module:Exchange
   agree, fetched live 2026-08-27): low-alch-f2p 12500 -> 18000, matching
   magic-alch-handoff. tools/validate.py gains a cross-entry supply-fact
   consistency check (same item+source must agree on ge_limit; run on
   every gate invocation) plus --selftest regression fixtures: diverging
   limits must fail, agreeing limits must pass, missing ge_limit is not
   gated.

3. SII tier is now phase-aware and translatable: actions split into
   cast-alch (LA/HLA -> cheapest alch candidate, noted stack) and
   cast-superheat (SII 43-55 -> unnoted ore whose live bar margin is
   positive; never a noted stack, never a price-only pick; on_fail skip
   when no positive-margin ore). State machine pins each tier path
   (LA <43, SII window chosen/skipped 43-55, HLA >=55), bank_supplies
   banks smelted bars, buy-supplies restricts ores to positive-margin
   recipes.

Tests: python3 tools/validate.py -> 2 entries, 0 errors, 0 warnings;
--selftest -> 3 PASS; negative test (reintroducing 12500) caught by the
new check; git diff --check clean.
This commit is contained in:
Claude 2026-08-27 12:46:12 -05:00
parent ca34e97366
commit 9a2f936c05
3 changed files with 130 additions and 17 deletions

View File

@ -42,7 +42,12 @@ economy:
net_per_cycle: -112
gp_per_hour_low: -250000 # negative: this burns GP
gp_per_hour_high: -200000
xp_per_hour: 55800 # 31 xp/cast (wiki) @ ~1800 casts/hr (3-tick, wiki)
xp_per_hour: 43400 # 31 xp/cast (wiki) @ 1,400 casts/hr pinned — one OSRS
# tick = 0.6s, 3-tick cast = 1.8s; mandatory post-action
# delay 1.6-3.4s (mid ~2.5s) -> 3,600/2.5 = ~1,440 casts/hr
# ceiling, pinned at 1,400 for bank-cycle overhead. The
# old 1,800 casts/hr (55,800 xp/hr) was not reachable
# under the entry's own pacing.
supplies:
- item: gold necklace
qty_per_cycle: 1
@ -51,7 +56,8 @@ economy:
- item: nature rune
qty_per_cycle: 1
source: ge
ge_limit: 12500
ge_limit: 18000 # mapping 'limit' + Module:Exchange/Nature_rune agree;
# matches magic-alch-handoff (consistency check enforces)
profit_drivers:
- "NONE at current prices — XP is the product, not GP"
- "Pick the candidate with the least-negative net each run (live rank)"
@ -122,7 +128,9 @@ actions:
on: "itself"
on_fail: retry
anti_ban:
delay: [1600, 3400] # 3-tick cast = ~5.4s game; stay under it, humanised
delay: [1600, 3400] # 3-tick cast = 1.8s game (3 ticks x 0.6s); post-action
# delay mid ~2.5s -> ~1,400 casts/hr pinned; stay under
# it, humanised
- id: a4
step: bank-loop

View File

@ -52,7 +52,14 @@ economy:
item: nature rune
item_id: 561
cost_per_cycle: 145 # one nature rune per cast (staff of fire), buy price 2026-08-27
xp_per_hour: 117000 # HLA tier (65 xp/cast, ~1800 casts/hr at the 3-tick spell speed — derived; LA tier 55800, SII tier 95400)
xp_per_hour: 91000 # HLA tier: 65 xp/cast @ 1,400 casts/hr pinned — one
# tick = 0.6s, 3-tick cast = 1.8s; mandatory post-action
# delay 1.6-3.4s (mid ~2.5s) -> 3,600/2.5 = ~1,440 casts/hr
# ceiling, pinned at 1,400 for bank-cycle overhead.
# Tiers: LA 43,400 (31 x 1400) / SII 74,200 (53 x 1400)
# / HLA 91,000 (65 x 1400). Old 1,800 casts/hr figures
# (55,800 / 95,400 / 117,000) were not reachable under
# the entry's own pacing.
supplies:
- item: nature rune
qty_per_cycle: 1
@ -71,10 +78,12 @@ state_machine:
goal: be at a bank with GE access, staff of fire equipped, nature runes in stock
next: [cast, bank_supplies, complete, failed]
cast:
goal: cast the current tier spell (LA below 43, SII 43-55 if chosen, HLA from 55)
goal: cast the active tier spell with the tier-correct target — LA < 43 on a
noted alch candidate; SII 43-55 on an unnoted ore whose live bar margin
is positive (never a noted stack); HLA >= 55 on a noted alch candidate
next: [cast, bank_supplies, complete, failed]
bank_supplies:
goal: deposit coins, restock nature runes (+ ores if running the SII window)
goal: deposit coins and smelted bars, restock nature runes (+ ores during the SII window)
next: [cast, idle]
complete:
goal: magic level target reached
@ -85,10 +94,16 @@ state_machine:
when: "nature rune count < buffer (or ore count < buffer in SII window)"
to: bank_supplies
- from: cast
when: "level 43 reached and the SII window is chosen (margins positive)"
when: "level < 43 and LA tier active (cast on the cheapest alch candidate)"
to: cast
- from: cast
when: "level 55 reached"
when: "level 43-55 and the SII window is chosen (some unnoted ore's bar margin is positive)"
to: cast
- from: cast
when: "level 43-55 and the SII window is skipped (no positive-margin ore this window) — stay on LA"
to: cast
- from: cast
when: "level >= 55 and HLA tier active (cast on the cheapest alch candidate)"
to: cast
- from: idle
when: "magic level >= target"
@ -111,7 +126,7 @@ actions:
verb: ge_buy
target: grand-exchange
params:
item: "nature rune (and ores during the SII window)"
item: "nature rune (and unnoted ores during the SII window — only recipes whose live bar margin is positive)"
qty: "fill to buffer"
max_price: "live low + 5%"
on_fail: retry
@ -119,24 +134,38 @@ actions:
delay: [1200, 3000]
- id: a3
step: cast-tier-spell
step: cast-alch
verb: use_item_on
target: "tier supply in inventory (noted stack for LA/HLA, ore for SII)"
target: "noted alch candidate in inventory (LA and HLA tiers)"
params:
spell: "Low Level Alchemy | Superheat Item | High Level Alchemy (per current tier)"
item: "cheapest live candidate in inventory"
spell: "Low Level Alchemy (level < 43) | High Level Alchemy (level >= 55)"
item: "cheapest live alch candidate in inventory — LA/HLA are alchemy tiers, so the selection rule is the alch-candidate rule"
on: "itself"
on_fail: retry
anti_ban:
delay: [1600, 3400] # 3-tick cast = ~5.4s game; stay under it, humanised
delay: [1600, 3400] # 3-tick cast = 1.8s game (3 ticks x 0.6s); post-action
# delay mid ~2.5s -> ~1,400 casts/hr pinned; stay under
# it, humanised
- id: a4
step: cast-superheat
verb: use_item_on
target: "unnoted ore in inventory (SII tier only, 43-55)"
params:
spell: "Superheat Item"
item: "an unnoted ore whose live bar margin is positive (bar GE price covers ore + nature cost); never a noted stack, never a price-only pick"
on: "itself"
on_fail: skip # no positive-margin ore -> skip SII, stay on LA
anti_ban:
delay: [1600, 3400]
- id: a5
step: bank-loop
verb: bank_inventory
target: bank
params:
deposit: [all coins, all products]
withdraw: "nature runes up to buffer (+ ores during the SII window)"
deposit: [all coins, all products] # products incl. smelted bars during SII
withdraw: "nature runes up to buffer (+ unnoted ores during the SII window)"
on_fail: alert
run_control:

View File

@ -11,11 +11,17 @@ Runs:
=> WARNING (entry should be re-verified, not dropped)
- `best_practice` lines whose source starts with "researched"
=> WARNING (citation TODO — file a follow-up issue)
- Cross-entry supply-fact consistency: the same (supply item,
source) across entries must carry the same ge_limit. A diverging
limit (e.g. nature rune "12500" in one entry vs "18000" in
another) is a silent fact error, not a per-entry choice — GE
buy limits are item-global.
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]
Usage: python3 tools/validate.py [--stale-days 30] [--selftest]
--selftest: run the supply-consistency regression fixtures only
"""
import datetime
import json
@ -41,12 +47,77 @@ def load_entries():
return sorted(out)
def supply_limit_conflicts(rows):
"""Cross-entry consistency for duplicate supply facts.
rows: iterable of (rel, data). The same (item, source) appearing in
more than one entry must agree on ge_limit — GE buy limits are a
property of the item, so a divergence is a silent fact error.
Returns a list of error strings.
"""
facts = {}
for rel, data in rows:
eco = data.get("economy") or {}
for sup in eco.get("supplies", []):
lim = sup.get("ge_limit")
if lim is None:
continue
key = (sup.get("item"), sup.get("source"))
facts.setdefault(key, {}).setdefault(lim, set()).add(rel)
errors = []
for (item, source), by_lim in sorted(facts.items()):
if len(by_lim) > 1:
detail = "; ".join(
f"limit {lim} in {', '.join(sorted(rs))}"
for lim, rs in sorted(by_lim.items()))
errors.append(
f"conflicting ge_limit for supply '{item}' (source {source}): {detail}")
return errors
def run_selftest(verbose=False):
"""Regression fixtures for the supply-consistency gate; error strings."""
fixtures = [
# name, rows, expect_error
("identical (item, source) with diverging ge_limit (18000 vs 12500)",
[("e1.yaml", {"economy": {"supplies": [
{"item": "nature rune", "source": "ge", "ge_limit": 18000}]}}),
("e2.yaml", {"economy": {"supplies": [
{"item": "nature rune", "source": "ge", "ge_limit": 12500}]}})], True),
("identical (item, source) with agreeing ge_limit (18000 both)",
[("e1.yaml", {"economy": {"supplies": [
{"item": "nature rune", "source": "ge", "ge_limit": 18000}]}}),
("e2.yaml", {"economy": {"supplies": [
{"item": "nature rune", "source": "ge", "ge_limit": 18000}]}})], False),
("no supplies / missing ge_limit are not gated",
[("e1.yaml", {"economy": {}}),
("e2.yaml", {"economy": {"supplies": [
{"item": "gold necklace", "source": "ge"}]}})], False),
]
errors = []
for name, rows, expect_error in fixtures:
got = bool(supply_limit_conflicts(rows))
passed = got == 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 else 'no error'}")
return errors
def main():
global STALE_DAYS
if "--stale-days" in sys.argv:
STALE_DAYS = int(sys.argv[sys.argv.index("--stale-days") + 1])
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
entries = load_entries()
if not entries:
print("ERROR: no codex entries found under codex/")
@ -54,6 +125,7 @@ def main():
ids = {}
today = datetime.date.today()
rows = []
for path in entries:
rel = str(path.relative_to(ROOT))
@ -62,6 +134,7 @@ def main():
except yaml.YAMLError as e:
errors.append(f"{rel}: YAML parse error: {e}")
continue
rows.append((rel, data))
# 1. schema
v = jsonschema.Draft7Validator(SCHEMA)
@ -125,6 +198,9 @@ def main():
f"{rel}: best_practice '{line.get('item') or line.get('name')}' "
f"carries a 'researched' source — citation TODO, file follow-up")
# 2b. cross-entry supply-fact consistency (GE buy limits are item-global)
errors += supply_limit_conflicts(rows)
# report
for w in warnings:
print(f"WARN {w}")