From 75a9965c9f413ea79c798e1efaf1ca1574a5d78e Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 27 Aug 2026 17:01:54 -0500 Subject: [PATCH] =?UTF-8?q?codex:=20bounce-fix=20attempt=202=20=E2=80=94?= =?UTF-8?q?=20additive=20pacing,=20cast-unit=20breaks,=20concrete=20SII=20?= =?UTF-8?q?recipes,=20validator=20crash=20guard=20(issue=20#5)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- codex/economy/low-alch-f2p.yaml | 29 ++-- codex/training/magic-alch-handoff.yaml | 181 +++++++++++++++++++++---- schemas/entry.schema.json | 32 +++++ tools/validate.py | 169 +++++++++++++++++++++-- 4 files changed, 354 insertions(+), 57 deletions(-) diff --git a/codex/economy/low-alch-f2p.yaml b/codex/economy/low-alch-f2p.yaml index ba33535..6dc8501 100644 --- a/codex/economy/low-alch-f2p.yaml +++ b/codex/economy/low-alch-f2p.yaml @@ -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" diff --git a/codex/training/magic-alch-handoff.yaml b/codex/training/magic-alch-handoff.yaml index 589271a..a6d7b34 100644 --- a/codex/training/magic-alch-handoff.yaml +++ b/codex/training/magic-alch-handoff.yaml @@ -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) diff --git a/schemas/entry.schema.json b/schemas/entry.schema.json index c50ce9b..a73c4e2 100644 --- a/schemas/entry.schema.json +++ b/schemas/entry.schema.json @@ -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"], diff --git a/tools/validate.py b/tools/validate.py index 267c1e1..7c34cc7 100644 --- a/tools/validate.py +++ b/tools/validate.py @@ -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: