codex: bounce-fix attempt 2 — additive pacing, cast-unit breaks, concrete SII recipes, validator crash guard (issue #5)
Governor-review (PR #10, attempt 2) findings, same issue-5 branch: 1. Pacing is additive. action-grammar.md defines anti_ban.delay as sleep AFTER the action, so a cast takes 1.8s + 1.6-3.4s = 3.4-5.2s (mid 4.3s), ~837 casts/hr, not 1,400 or 1,800. Both entries now pin the midpoint rate with the formula documented: LA 25,953 / SII 44,372 / HLA 54,419 xp/hr. low-alch-f2p gp_per_hour bracketed by the delay endpoints (-119,000 / -78,000). 2. run_control units agree: schema.md 'every' counts iterations and iteration is one cast, so break.every [8, 14] is casts (comment no longer claims bank cycles). 3. SII is now a concrete recipe. New top-level recipes: table models all eight F2P smithable bars (bronze/iron/silver/steel/gold/mithril/ adamantite/runite) with every input and quantity (coal 2/4/6/8, Smithing gates, bar ids) from the OSRS wiki smelting table. The cast-superheat action pins params.recipe (steel-bar anchor) plus a recipe_rule: smithing gate + complete input cost + one nature rune < bar price; buy/withdraw the exact unnoted inputs; bank the bar. entry.schema.json gains the optional 'recipes' section. Validator gains recipe/SII checks and a deterministic steel-bar fixture (1 iron + 2 coal = 3 inputs; margin 600 - (100 + 2*150 + 145) = 55). 4. Validator no longer crashes on malformed docs: scalar/empty YAML is rejected with a normal ERROR (document shape check) and supply_limit_conflicts guards non-mapping rows; regression fixtures cover (bad.yaml, None), scalar docs, and undeclared recipe ids. Tests: python3 tools/validate.py -> 2 entries, 0 errors, 0 warnings (exit 0); --selftest -> 14 PASS; live negatives: scalar + empty docs error cleanly (exit 1, no traceback), reintroduced 12500 divergence is caught (supply-fact gate), git diff --check clean.
This commit is contained in:
parent
9a2f936c05
commit
75a9965c9f
@ -40,14 +40,16 @@ economy:
|
||||
cost_per_cycle: 292 # necklace buy 148 + nature rune 144 (staff of fire)
|
||||
income_per_cycle: 180 # lowalch of gold necklace (40% of value 450)
|
||||
net_per_cycle: -112
|
||||
gp_per_hour_low: -250000 # negative: this burns GP
|
||||
gp_per_hour_high: -200000
|
||||
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.
|
||||
gp_per_hour_low: -119000 # -112 net/cast x rate endpoints: max burn at 1.6s delay
|
||||
# (3.6/3.4s = 1,058 casts/hr) ~ -118,500; mid 4.3s/cast
|
||||
# (837 casts/hr) ~ -93,800
|
||||
gp_per_hour_high: -78000 # min burn at 3.4s delay (3.6/5.2s = 692 casts/hr) ~ -77,500
|
||||
xp_per_hour: 25953 # 31 xp/cast (wiki) @ ~837 casts/hr — additive wall time:
|
||||
# 3-tick cast = 1.8s PLUS anti_ban.delay 1.6-3.4s which is
|
||||
# sleep AFTER the action (action-grammar.md) = 3.4-5.2s/cast,
|
||||
# mid 4.3s -> 3,600/4.3 = ~837 casts/hr (midpoint rate).
|
||||
# The old 1,800 casts/hr (55,800 xp/hr) ignored the additive
|
||||
# delay and was not reachable under the entry's own pacing.
|
||||
supplies:
|
||||
- item: gold necklace
|
||||
qty_per_cycle: 1
|
||||
@ -128,9 +130,9 @@ actions:
|
||||
on: "itself"
|
||||
on_fail: retry
|
||||
anti_ban:
|
||||
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
|
||||
delay: [1600, 3400] # sleep AFTER the action (action-grammar.md): 1.8s cast +
|
||||
# 1.6-3.4s delay = 3.4-5.2s/cast, mid 4.3s -> ~837 casts/hr
|
||||
# mid; xp_per_hour pinned at that additive rate, humanised
|
||||
|
||||
- id: a4
|
||||
step: bank-loop
|
||||
@ -142,10 +144,11 @@ actions:
|
||||
on_fail: alert
|
||||
|
||||
run_control:
|
||||
iteration: "one cast"
|
||||
iteration: "one cast" # schema.md: break.every counts iterations, so every
|
||||
# value below is in casts (one iteration = one cast)
|
||||
max_iterations: null
|
||||
break:
|
||||
every: [8, 14] # bank cycles (breaks land on bank visits)
|
||||
every: [8, 14] # casts (one iteration = one cast); no bank-visit alignment assumed
|
||||
for: [45, 120] # seconds of idle AFK
|
||||
stop_conditions:
|
||||
- "magic level >= target"
|
||||
|
||||
@ -32,8 +32,11 @@ best_practice:
|
||||
why: "LA page: 'Players with level 55 Magic are generally recommended to use High Level Alchemy instead, as it provides more coins and experience per cast' — 65 vs 31 xp/cast, 60% vs 40% of item value"
|
||||
source: "OSRS wiki — Low Level Alchemy (Comparison with High Level Alchemy); High Level Alchemy (infobox: level 55, 65 xp)"
|
||||
- name: optional Superheat Item window 43-55
|
||||
why: "SII unlocks at 43 (53 xp/cast) and smelts ore into bars without a furnace — a training window that can profit when ore/bar margins are positive; it cannot be cast on noted ore, so it needs an ore inventory rather than a note stack"
|
||||
why: "SII unlocks at 43 (53 xp/cast) and smelts ore into bars without a furnace — a training window that can profit when ore/bar margins are positive; it cannot be cast on noted ore, so it needs the exact recipe inputs in inventory rather than a note stack"
|
||||
source: "OSRS wiki — Superheat Item (43 Magic, 53 xp, F2P; 'smelt ore into bars without a furnace'; 'cannot be cast on noted ore')"
|
||||
- name: select SII bars by complete recipe margin
|
||||
why: "SII smelts a concrete bar from its exact inputs (see the recipes table below) — the bar's Smithing gate must be met and the complete input cost (all ores + coal + one nature rune, live GE buy) must stay under the bar's GE price; withdraw those exact inputs and bank the produced bar"
|
||||
source: "OSRS wiki — Superheat Item (requires the Smithing level and materials of the bar); Smithing (bar smelting table); prices.runescape.wiki (live GE prices)"
|
||||
- name: buy GE items below alch value, cast on noted items at a bank
|
||||
why: "HLA page: 'Players who train this way can break even on the cost of runes, or even turn a profit, by buying items on the Grand Exchange for a lower cost than the item's High Alchemy value'; all alchemy spells work on noted items, saving bank withdrawals per cast"
|
||||
source: "OSRS wiki — High Level Alchemy (GE buying for break-even/profit; noted-item casting)"
|
||||
@ -48,18 +51,19 @@ economy:
|
||||
# members=false, lowalch 72 = 40% of value 180 — consistency assertion).
|
||||
# Income/net per cast depend on the alched item, so they live in the
|
||||
# per-activity entries (low-alch-f2p, f2p-alch-catalog): this entry only
|
||||
# prices the shared consumable.
|
||||
# prices the shared consumable. SII recipe economics live in the recipes
|
||||
# table below.
|
||||
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: 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.
|
||||
xp_per_hour: 54419 # HLA tier: 65 xp/cast @ ~837 casts/hr — additive wall
|
||||
# time: 3-tick cast = 1.8s PLUS anti_ban.delay 1.6-3.4s
|
||||
# after the action (action-grammar.md) = 3.4-5.2s/cast,
|
||||
# mid 4.3s -> 3,600/4.3 = ~837 casts/hr. Tiers pinned at
|
||||
# the midpoint rate: LA 25,953 (31 x 837)
|
||||
# / SII 44,372 (53 x 837) / HLA 54,419 (65 x 837).
|
||||
# Old 1,400-1,800 casts/hr figures (43,400 / 74,200 /
|
||||
# 91,000) ignored the additive delay — unreachable.
|
||||
supplies:
|
||||
- item: nature rune
|
||||
qty_per_cycle: 1
|
||||
@ -68,7 +72,117 @@ economy:
|
||||
profit_drivers:
|
||||
- "XP per cast by tier (wiki infoboxes): LA 31 (level 21) / SII 53 (level 43) / HLA 65 (level 55)"
|
||||
- "HLA can break even or profit on GE-bought items priced below their High Alchemy value (HLA page); LA is a loss at current prices (see low-alch-f2p)"
|
||||
- "SII turns a profit only when ore + nature costs stay under the resulting bar's GE price — re-check margins per window (SII page)"
|
||||
- "SII turns a profit only when the complete recipe cost (all inputs + nature rune) stays under the resulting bar's GE price — re-check margins per window (SII page; the recipes table)"
|
||||
|
||||
# Concrete F2P bar recipes for Superheat Item. SII requires the bar's
|
||||
# Smithing level and its exact materials, so every selectable bar is modelled
|
||||
# with ALL inputs and quantities (id 453 coal appears in steel/mithril/
|
||||
# adamantite/runite). Levels, xp and item ids from the OSRS wiki (Smithing
|
||||
# smelting table; Superheat Item grants the bar's Smithing XP per cast).
|
||||
# econ 2026-08-27: bar margin = bar GE price - (all inputs at live GE buy +
|
||||
# one nature rune 145); the client selects the highest-margin recipe whose
|
||||
# smithing_level <= current Smithing level and withdraws its exact inputs.
|
||||
recipes:
|
||||
bronze-bar:
|
||||
bar: Bronze bar
|
||||
bar_item_id: 2349
|
||||
smithing_level: 1
|
||||
smithing_xp: 6.2
|
||||
inputs:
|
||||
- item: Copper ore
|
||||
item_id: 436
|
||||
qty: 1
|
||||
source: ge
|
||||
- item: Tin ore
|
||||
item_id: 438
|
||||
qty: 1
|
||||
source: ge
|
||||
iron-bar:
|
||||
bar: Iron bar
|
||||
bar_item_id: 2351
|
||||
smithing_level: 15
|
||||
smithing_xp: 12.5
|
||||
inputs:
|
||||
- item: Iron ore
|
||||
item_id: 440
|
||||
qty: 1
|
||||
source: ge
|
||||
silver-bar:
|
||||
bar: Silver bar
|
||||
bar_item_id: 2355
|
||||
smithing_level: 20
|
||||
smithing_xp: 13.7
|
||||
inputs:
|
||||
- item: Silver ore
|
||||
item_id: 442
|
||||
qty: 1
|
||||
source: ge
|
||||
steel-bar:
|
||||
bar: Steel bar
|
||||
bar_item_id: 2353
|
||||
smithing_level: 30
|
||||
smithing_xp: 17.5
|
||||
inputs:
|
||||
- item: Iron ore
|
||||
item_id: 440
|
||||
qty: 1
|
||||
source: ge
|
||||
- item: Coal
|
||||
item_id: 453
|
||||
qty: 2
|
||||
source: ge
|
||||
gold-bar:
|
||||
bar: Gold bar
|
||||
bar_item_id: 2357
|
||||
smithing_level: 40
|
||||
smithing_xp: 22.5
|
||||
inputs:
|
||||
- item: Gold ore
|
||||
item_id: 444
|
||||
qty: 1
|
||||
source: ge
|
||||
mithril-bar:
|
||||
bar: Mithril bar
|
||||
bar_item_id: 2359
|
||||
smithing_level: 50
|
||||
smithing_xp: 30
|
||||
inputs:
|
||||
- item: Mithril ore
|
||||
item_id: 447
|
||||
qty: 1
|
||||
source: ge
|
||||
- item: Coal
|
||||
item_id: 453
|
||||
qty: 4
|
||||
source: ge
|
||||
adamantite-bar:
|
||||
bar: Adamantite bar
|
||||
bar_item_id: 2361
|
||||
smithing_level: 70
|
||||
smithing_xp: 37.5
|
||||
inputs:
|
||||
- item: Adamantite ore
|
||||
item_id: 449
|
||||
qty: 1
|
||||
source: ge
|
||||
- item: Coal
|
||||
item_id: 453
|
||||
qty: 6
|
||||
source: ge
|
||||
runite-bar:
|
||||
bar: Runite bar
|
||||
bar_item_id: 2363
|
||||
smithing_level: 85
|
||||
smithing_xp: 50
|
||||
inputs:
|
||||
- item: Runite ore
|
||||
item_id: 451
|
||||
qty: 1
|
||||
source: ge
|
||||
- item: Coal
|
||||
item_id: 453
|
||||
qty: 8
|
||||
source: ge
|
||||
|
||||
state_machine:
|
||||
initial: idle
|
||||
@ -79,11 +193,12 @@ state_machine:
|
||||
next: [cast, bank_supplies, complete, failed]
|
||||
cast:
|
||||
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
|
||||
noted alch candidate; SII 43-55 on an unnoted input of the selected
|
||||
recipe (all exact inputs withdrawn; see the recipes table); HLA >= 55
|
||||
on a noted alch candidate
|
||||
next: [cast, bank_supplies, complete, failed]
|
||||
bank_supplies:
|
||||
goal: deposit coins and smelted bars, restock nature runes (+ ores during the SII window)
|
||||
goal: deposit coins and smelted bars, restock nature runes (+ the exact recipe inputs during the SII window)
|
||||
next: [cast, idle]
|
||||
complete:
|
||||
goal: magic level target reached
|
||||
@ -91,16 +206,16 @@ state_machine:
|
||||
goal: hard failure — stop, log ERROR sentinel, alert
|
||||
transitions:
|
||||
- from: cast
|
||||
when: "nature rune count < buffer (or ore count < buffer in SII window)"
|
||||
when: "nature rune count < buffer (or a selected recipe's inputs < buffer in SII window)"
|
||||
to: bank_supplies
|
||||
- from: cast
|
||||
when: "level < 43 and LA tier active (cast on the cheapest alch candidate)"
|
||||
to: cast
|
||||
- from: cast
|
||||
when: "level 43-55 and the SII window is chosen (some unnoted ore's bar margin is positive)"
|
||||
when: "level 43-55 and the SII window is chosen (a recipes-table entry has smithing_level <= current Smithing and a positive complete-cost margin)"
|
||||
to: cast
|
||||
- from: cast
|
||||
when: "level 43-55 and the SII window is skipped (no positive-margin ore this window) — stay on LA"
|
||||
when: "level 43-55 and the SII window is skipped (no recipes-table entry has a positive margin this window) — stay on LA"
|
||||
to: cast
|
||||
- from: cast
|
||||
when: "level >= 55 and HLA tier active (cast on the cheapest alch candidate)"
|
||||
@ -126,7 +241,7 @@ actions:
|
||||
verb: ge_buy
|
||||
target: grand-exchange
|
||||
params:
|
||||
item: "nature rune (and unnoted ores during the SII window — only recipes whose live bar margin is positive)"
|
||||
item: "nature rune + the exact SII recipe inputs (unnoted) of the selected recipe — every input and quantity from the recipes table (e.g. steel-bar = 1 iron ore + 2 coal), only while its complete-cost margin is positive"
|
||||
qty: "fill to buffer"
|
||||
max_price: "live low + 5%"
|
||||
on_fail: retry
|
||||
@ -143,21 +258,26 @@ actions:
|
||||
on: "itself"
|
||||
on_fail: retry
|
||||
anti_ban:
|
||||
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
|
||||
delay: [1600, 3400] # sleep AFTER the action (action-grammar.md): 1.8s cast +
|
||||
# 1.6-3.4s delay = 3.4-5.2s/cast, mid 4.3s -> ~837 casts/hr
|
||||
# mid; xp_per_hour pinned at that additive rate, humanised
|
||||
|
||||
- id: a4
|
||||
step: cast-superheat
|
||||
verb: use_item_on
|
||||
target: "unnoted ore in inventory (SII tier only, 43-55)"
|
||||
target: "an unnoted input of the selected recipe 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"
|
||||
recipe: "steel-bar" # concrete recipes-table entry — every input and quantity,
|
||||
# smithing gate, bar output (steel-bar = 1 iron ore +
|
||||
# 2 coal, Smithing 30). Any recipes-table bar may be
|
||||
# selected live; steel-bar is the fixture anchor.
|
||||
recipe_rule: "select a recipe from the recipes table whose smithing_level <= current Smithing level and whose complete input cost (ALL inputs, live GE buy) + one nature rune < the bar's live GE price; withdraw those exact inputs (unnoted) and cast on one of them; never a noted stack, never a price-only pick"
|
||||
on: "itself"
|
||||
on_fail: skip # no positive-margin ore -> skip SII, stay on LA
|
||||
on_fail: skip # no positive-margin recipe -> skip SII, stay on LA
|
||||
anti_ban:
|
||||
delay: [1600, 3400]
|
||||
delay: [1600, 3400] # post-action sleep (additive): 1.8s cast + delay (mid 2.5s)
|
||||
# = 4.3s/cast mid -> ~837 casts/hr, same pacing as a3
|
||||
|
||||
- id: a5
|
||||
step: bank-loop
|
||||
@ -165,14 +285,15 @@ actions:
|
||||
target: bank
|
||||
params:
|
||||
deposit: [all coins, all products] # products incl. smelted bars during SII
|
||||
withdraw: "nature runes up to buffer (+ unnoted ores during the SII window)"
|
||||
withdraw: "nature runes up to buffer + the exact unnoted inputs of the selected SII recipe (per the recipes table)"
|
||||
on_fail: alert
|
||||
|
||||
run_control:
|
||||
iteration: "one cast"
|
||||
iteration: "one cast" # schema.md: break.every counts iterations, so every
|
||||
# value below is in casts (one iteration = one cast)
|
||||
max_iterations: null
|
||||
break:
|
||||
every: [8, 14] # bank cycles (breaks land on bank visits)
|
||||
every: [8, 14] # casts (one iteration = one cast); no bank-visit alignment assumed
|
||||
for: [45, 120] # seconds of idle AFK
|
||||
stop_conditions:
|
||||
- "magic level >= target"
|
||||
@ -184,12 +305,14 @@ risk:
|
||||
notes:
|
||||
- "3-tick cast cadence is the main detection vector; keep anti_ban delays."
|
||||
- "The valuable-item warning fires on item GE value (not alch value) — the script must handle the warning widget when alching high-value items (HLA page)."
|
||||
- "SII: withdraw the exact recipe inputs (unnoted, per the recipes table) before casting — every cast consumes one of each; bank the produced bar after each cast; never cast on noted ores."
|
||||
- "SII ore-buy bursts are pace-sensitive like any GE buy; respect the 4h limit window."
|
||||
|
||||
sources:
|
||||
- "OSRS wiki — Low Level Alchemy (21 Magic, 31 xp/cast, members=No, 3-tick, 40% of item value; 'recommended to use High Level Alchemy instead' at 55 Magic)"
|
||||
- "OSRS wiki — Superheat Item (43 Magic, 53 xp/cast, members=No; smelts ore into bars without a furnace; cannot be cast on noted ore)"
|
||||
- "OSRS wiki — Superheat Item (43 Magic, 53 xp/cast, members=No; smelts ore into bars without a furnace; cannot be cast on noted ore; grants the bar's Smithing XP)"
|
||||
- "OSRS wiki — High Level Alchemy (55 Magic, 65 xp/cast, members=No, 60% of item value; GE buying for break-even/profit; noted-item casting; valuable-item warning)"
|
||||
- "OSRS wiki — Smithing (bar smelting table: smithing levels, per-bar XP and ore/coal quantities for bronze/iron/silver/steel/gold/mithril/adamantite/runite)"
|
||||
- "prices.runescape.wiki/api/v1/osrs/mapping (id 561 nature rune: members=false, limit 18000, value 180, lowalch 72; limit cross-checked against Module:Exchange/Nature_rune — agree)"
|
||||
- "prices.runescape.wiki/api/v1/osrs/latest (nature rune buy 145, snapshot 2026-08-27)"
|
||||
verified: "2026-08-27" # keep quoted (YAML 1.1 date parsing)
|
||||
|
||||
@ -62,6 +62,38 @@
|
||||
}
|
||||
},
|
||||
|
||||
"recipes": {
|
||||
"type": "object",
|
||||
"minProperties": 1,
|
||||
"propertyNames": { "pattern": "^[a-z0-9]+(-[a-z0-9]+)*$" },
|
||||
"additionalProperties": {
|
||||
"type": "object",
|
||||
"required": ["bar", "smithing_level", "inputs"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"bar": { "type": "string", "minLength": 2 },
|
||||
"bar_item_id": { "type": "integer", "minimum": 1 },
|
||||
"smithing_level": { "type": "integer", "minimum": 1, "maximum": 126 },
|
||||
"smithing_xp": { "type": "number", "exclusiveMinimum": 0 },
|
||||
"inputs": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"items": {
|
||||
"type": "object",
|
||||
"required": ["item", "qty", "source"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"item": { "type": "string", "minLength": 2 },
|
||||
"item_id": { "type": "integer", "minimum": 1 },
|
||||
"qty": { "type": "integer", "minimum": 1 },
|
||||
"source": { "enum": ["ge", "p2p", "drops", "craft", "bank", "npc-shop"] }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
"best_practice": {
|
||||
"type": "object",
|
||||
"required": ["equipment"],
|
||||
|
||||
@ -16,12 +16,21 @@ Runs:
|
||||
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.
|
||||
- SII translatability: an action that casts Superheat Item must
|
||||
name a concrete recipe id declared in the entry's `recipes:`
|
||||
table (all inputs + quantities, smithing gate, bar output). The
|
||||
client translates a bar from its exact recipe, not from a vague
|
||||
"positive margin ore" rule.
|
||||
3. Global id uniqueness across the whole codex tree.
|
||||
|
||||
Malformed/scalar YAML documents (an empty doc, a bare string, a list)
|
||||
are rejected with a normal validator ERROR — they never reach the
|
||||
cross-entry gates and never crash the tool with a traceback.
|
||||
|
||||
Exit codes: 0 = clean (warnings ok), 1 = validation errors.
|
||||
|
||||
Usage: python3 tools/validate.py [--stale-days 30] [--selftest]
|
||||
--selftest: run the supply-consistency regression fixtures only
|
||||
--selftest: run the regression fixtures only
|
||||
"""
|
||||
import datetime
|
||||
import json
|
||||
@ -47,6 +56,26 @@ def load_entries():
|
||||
return sorted(out)
|
||||
|
||||
|
||||
def document_error(rel, data):
|
||||
"""Shape check: an entry must parse to a YAML mapping.
|
||||
|
||||
Returns an error string, or None for a well-formed mapping. The
|
||||
caller keeps malformed/scalar documents out of the shared row set
|
||||
so downstream gates never see None/scalars (regression: a scalar
|
||||
document used to crash `supply_limit_conflicts` with an
|
||||
AttributeError instead of reporting a validation error).
|
||||
"""
|
||||
if isinstance(data, dict):
|
||||
return None
|
||||
if data is None:
|
||||
got = "an empty document"
|
||||
elif isinstance(data, (list, tuple)):
|
||||
got = f"a {type(data).__name__} document"
|
||||
else:
|
||||
got = f"{type(data).__name__} ({data!r})"
|
||||
return f"{rel}: document is not a YAML mapping (parsed as {got})"
|
||||
|
||||
|
||||
def supply_limit_conflicts(rows):
|
||||
"""Cross-entry consistency for duplicate supply facts.
|
||||
|
||||
@ -57,6 +86,8 @@ def supply_limit_conflicts(rows):
|
||||
"""
|
||||
facts = {}
|
||||
for rel, data in rows:
|
||||
if not isinstance(data, dict): # never crash on malformed rows
|
||||
continue
|
||||
eco = data.get("economy") or {}
|
||||
for sup in eco.get("supplies", []):
|
||||
lim = sup.get("ge_limit")
|
||||
@ -75,10 +106,66 @@ def supply_limit_conflicts(rows):
|
||||
return errors
|
||||
|
||||
|
||||
def recipe_total_input_qty(recipe):
|
||||
"""Total input count of a recipe — every input and quantity counts.
|
||||
|
||||
A multi-input recipe (e.g. steel-bar = 1 iron ore + 2 coal) must
|
||||
account for all inputs, not just the headline ore.
|
||||
"""
|
||||
return sum(int(inp.get("qty", 0)) for inp in recipe.get("inputs", []))
|
||||
|
||||
|
||||
def recipe_margin(recipe, bar_price, input_prices, nature_price):
|
||||
"""Complete-recipe margin for Superheat Item.
|
||||
|
||||
bar GE price minus (ALL inputs at live GE buy prices + one nature
|
||||
rune). Negative/zero => the bar is not worth producing with SII.
|
||||
This is the exact formula the entries pin; tests use it with fixed
|
||||
prices so the fixture is deterministic.
|
||||
"""
|
||||
cost = sum(
|
||||
int(inp.get("qty", 0)) * int(input_prices.get(inp.get("item"), 0))
|
||||
for inp in recipe.get("inputs", []))
|
||||
return bar_price - (cost + nature_price)
|
||||
|
||||
|
||||
def recipe_errors(rel, data):
|
||||
"""Entry-level SII translatability checks. Returns error strings."""
|
||||
errors = []
|
||||
recipes = data.get("recipes")
|
||||
if recipes is not None and not isinstance(recipes, dict):
|
||||
return [
|
||||
f"{rel}: 'recipes' must be a mapping of recipe id -> recipe definition"]
|
||||
for action in data.get("actions", []):
|
||||
params = action.get("params") or {}
|
||||
spell = str(params.get("spell", ""))
|
||||
if "superheat" not in spell.lower():
|
||||
continue
|
||||
rid = params.get("recipe")
|
||||
if not isinstance(rid, str) or not rid:
|
||||
errors.append(
|
||||
f"{rel}: action {action.get('id')} casts Superheat Item but has no "
|
||||
f"params.recipe naming a concrete recipes: entry (the client cannot "
|
||||
f"translate an ore choice into a bar without the full recipe)")
|
||||
elif recipes is None or rid not in recipes:
|
||||
errors.append(
|
||||
f"{rel}: action {action.get('id')} references recipe '{rid}' which "
|
||||
f"is not declared in recipes:")
|
||||
return errors
|
||||
|
||||
|
||||
def run_selftest(verbose=False):
|
||||
"""Regression fixtures for the supply-consistency gate; error strings."""
|
||||
"""Regression fixtures for the cross-field gates; error strings."""
|
||||
errors = []
|
||||
|
||||
def check(name, passed):
|
||||
if verbose:
|
||||
print(f"SELFTEST {'PASS' if passed else 'FAIL'} {name}")
|
||||
if not passed:
|
||||
errors.append(f"selftest: {name}")
|
||||
|
||||
# --- supply-consistency fixtures ---
|
||||
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}]}}),
|
||||
@ -94,17 +181,61 @@ def run_selftest(verbose=False):
|
||||
("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'}")
|
||||
check(name, got == expect_error)
|
||||
|
||||
# --- malformed-document regression: no traceback, normal errors ---
|
||||
check("supply_limit_conflicts handles (bad.yaml, None) rows without crashing",
|
||||
supply_limit_conflicts([("bad.yaml", None)]) == [])
|
||||
for val in (None, "just a scalar", [1, 2, 3]):
|
||||
msg = document_error("bad.yaml", val)
|
||||
check(
|
||||
f"document_error rejects {type(val).__name__} docs with an ERROR, no raise",
|
||||
isinstance(msg, str) and "not a YAML mapping" in msg)
|
||||
check("document_error accepts mapping documents",
|
||||
document_error("ok.yaml", {"id": "x"}) is None)
|
||||
|
||||
# --- deterministic multi-input recipe fixture: steel-bar (1 iron + 2 coal) ---
|
||||
steel = {"bar": "Steel bar", "bar_item_id": 2353, "smithing_level": 30,
|
||||
"inputs": [
|
||||
{"item": "Iron ore", "item_id": 440, "qty": 1, "source": "ge"},
|
||||
{"item": "Coal", "item_id": 453, "qty": 2, "source": "ge"}]}
|
||||
check("steel-bar recipe totals 3 inputs (1 iron ore + 2 coal)",
|
||||
recipe_total_input_qty(steel) == 3)
|
||||
# cost = 100*1 + 150*2 = 400; bar 600 - (400 + nature 145) = +55
|
||||
check("steel-bar complete-recipe margin = 55 (bar 600 - (iron 100 + 2*coal 150 + nature 145))",
|
||||
recipe_margin(steel, bar_price=600,
|
||||
input_prices={"Iron ore": 100, "Coal": 150},
|
||||
nature_price=145) == 55)
|
||||
check("steel-bar margin goes negative when the bar price no longer covers the full recipe",
|
||||
recipe_margin(steel, bar_price=500,
|
||||
input_prices={"Iron ore": 100, "Coal": 150},
|
||||
nature_price=145) < 0)
|
||||
|
||||
# --- SII translatability fixtures ---
|
||||
entry_ok = {"actions": [
|
||||
{"id": "a4", "step": "cast-superheat", "verb": "use_item_on",
|
||||
"params": {"spell": "Superheat Item", "recipe": "steel-bar"},
|
||||
"on_fail": "skip"}],
|
||||
"recipes": {"steel-bar": steel}}
|
||||
entry_undeclared = {"actions": [
|
||||
{"id": "a4", "step": "cast-superheat", "verb": "use_item_on",
|
||||
"params": {"spell": "Superheat Item", "recipe": "steel-bar"},
|
||||
"on_fail": "skip"}]}
|
||||
entry_unpinned = {"actions": [
|
||||
{"id": "a4", "step": "cast-superheat", "verb": "use_item_on",
|
||||
"params": {"spell": "Superheat Item"}, "on_fail": "skip"}],
|
||||
"recipes": {"steel-bar": steel}}
|
||||
check("Superheat action naming a declared recipes: id passes",
|
||||
recipe_errors("ok.yaml", entry_ok) == [])
|
||||
undeclared = recipe_errors("bad.yaml", entry_undeclared)
|
||||
check("Superheat action naming an undeclared recipe id errors",
|
||||
any("not declared in recipes:" in e for e in undeclared))
|
||||
unpinned = recipe_errors("bad.yaml", entry_unpinned)
|
||||
check("Superheat action without params.recipe errors",
|
||||
any("no params.recipe" in e for e in unpinned))
|
||||
|
||||
return errors
|
||||
|
||||
|
||||
@ -134,15 +265,20 @@ def main():
|
||||
except yaml.YAMLError as e:
|
||||
errors.append(f"{rel}: YAML parse error: {e}")
|
||||
continue
|
||||
shape_err = document_error(rel, data)
|
||||
if shape_err:
|
||||
errors.append(shape_err)
|
||||
continue
|
||||
rows.append((rel, data))
|
||||
|
||||
# 1. schema
|
||||
v = jsonschema.Draft7Validator(SCHEMA)
|
||||
for err in sorted(v.iter_errors(data), key=lambda e: list(e.path)):
|
||||
schema_errors = sorted(v.iter_errors(data), key=lambda e: list(e.path))
|
||||
for err in schema_errors:
|
||||
loc = "/".join(str(p) for p in err.path) or "(root)"
|
||||
errors.append(f"{rel} [{loc}]: {err.message}")
|
||||
if errors and errors[-1].startswith(rel):
|
||||
# schema already broke; cross-checks on a broken doc are noise
|
||||
if schema_errors:
|
||||
# cross-checks on a schema-invalid doc are noise
|
||||
continue
|
||||
|
||||
eid = data.get("id", rel)
|
||||
@ -180,6 +316,9 @@ def main():
|
||||
if len(a_steps) != len(set(a_steps)):
|
||||
errors.append(f"{rel}: duplicate action step names")
|
||||
|
||||
# SII translatability (concrete recipes: entry per Superheat action)
|
||||
errors += recipe_errors(rel, data)
|
||||
|
||||
# 3. staleness / research TODOs
|
||||
if data.get("status") == "verified":
|
||||
try:
|
||||
|
||||
Loading…
Reference in New Issue
Block a user