From 68c1345660006413d681b40d26e430fe55003511 Mon Sep 17 00:00:00 2001 From: mlangkabel Date: Sat, 13 Jun 2026 10:59:22 +0200 Subject: [PATCH] recipe iteration --- bin/app/data/config/recipes.toml | 442 +++++++++++++++++++++++++++---- bin/app/data/config/visuals.toml | 156 +++++++++-- docs/content_design.md | 64 ++++- tools/verify_recipes.py | 132 +++++++++ 4 files changed, 715 insertions(+), 79 deletions(-) create mode 100644 tools/verify_recipes.py diff --git a/bin/app/data/config/recipes.toml b/bin/app/data/config/recipes.toml index 363ea4e..dd80f09 100644 --- a/bin/app/data/config/recipes.toml +++ b/bin/app/data/config/recipes.toml @@ -1,3 +1,29 @@ +# recipes.toml +# +# First real-content iteration of the production tree. Quantities and +# durations are a first guess; the balancing pass will tune them and assign +# real unlock_at_station_level values (everything is unlocked for now so the +# full tree is testable). +# +# Input chain per game phase — each phase adds exactly one new base input: +# +# early iron_ore + copper_ore -> ingots -> copper_wire, steel_plate, +# circuit_board +# mid + titanium_ore -> titanium_frame; assembler-made +# mechanical_parts, targeting_unit, +# drive_unit +# late + advanced_alloy -> reinforced_plating, capital_core. +# advanced_alloy CANNOT be mined; it only +# comes from reprocessing salvaged scrap, +# so capital production requires combat. +# +# Run tools/verify_recipes.py after editing to check that every consumed +# item has a producer and every item has a visuals.toml entry. + +# ----------------------------------------------------------------------------- +# Mining (tier 0) +# ----------------------------------------------------------------------------- + [[recipe]] id = "mine_iron_ore" building = "miner" @@ -12,6 +38,18 @@ inputs = [] outputs = [{item = "copper_ore", amount = 1}] duration_seconds = 1.5 +# Titanium is the midgame ore: mined three times slower than iron. +[[recipe]] +id = "mine_titanium_ore" +building = "miner" +inputs = [] +outputs = [{item = "titanium_ore", amount = 1}] +duration_seconds = 3.0 + +# ----------------------------------------------------------------------------- +# Smelting (tier 1) +# ----------------------------------------------------------------------------- + [[recipe]] id = "iron_ingot" building = "smelter" @@ -27,54 +65,17 @@ outputs = [{item = "copper_ingot", amount = 1}] duration_seconds = 2.5 [[recipe]] -id = "circuit_board" -building = "assembler" -inputs = [{item = "iron_ingot", amount = 1}, {item = "copper_ingot", amount = 2}] -outputs = [{item = "circuit_board", amount = 1}] -duration_seconds = 2.0 - -[[recipe]] -id = "drone_hull" -building = "assembler" -inputs = [{item = "iron_ingot", amount = 5}, {item = "circuit_board", amount = 1}] -outputs = [{item = "drone_hull", amount = 1}] +id = "titanium_ingot" +building = "smelter" +inputs = [{item = "titanium_ore", amount = 3}] +outputs = [{item = "titanium_ingot", amount = 1}] duration_seconds = 4.0 -[[recipe]] -id = "laser_cannon_xs_module" -building = "assembler" -inputs = [{item = "iron_ingot", amount = 2}, {item = "circuit_board", amount = 1}] -outputs = [{item = "laser_cannon_xs_module", amount = 1}] -duration_seconds = 3.0 - -[[recipe]] -id = "laser_cannon_s_module" -building = "assembler" -inputs = [{item = "iron_ingot", amount = 4}, {item = "circuit_board", amount = 2}] -outputs = [{item = "laser_cannon_s_module", amount = 1}] -duration_seconds = 6.0 - -[[recipe]] -id = "salvager_module" -building = "assembler" -inputs = [{item = "iron_ingot", amount = 1}, {item = "circuit_board", amount = 1}] -outputs = [{item = "salvager_module", amount = 1}] -duration_seconds = 6.0 - -[[recipe]] -id = "repair_tool_module" -building = "assembler" -inputs = [{item = "iron_ingot", amount = 1}, {item = "circuit_board", amount = 2}] -outputs = [{item = "repair_tool_module", amount = 1}] -duration_seconds = 6.0 - -[[recipe]] -id = "building_blocks" -unlock_at_station_level = -1 -building = "assembler" -inputs = [{item = "iron_ingot", amount = 4}] -outputs = [{item = "building_block", amount = 10}] -duration_seconds = 4.0 +# ----------------------------------------------------------------------------- +# Reprocessing +# +# The only source of advanced_alloy: salvaged scrap from destroyed ships. +# ----------------------------------------------------------------------------- [[recipe]] id = "reprocessing_cycle" @@ -85,15 +86,354 @@ duration_seconds = 3.0 [[recipe.outputs]] item = "iron_ingot" amount = 2 - probability = 0.6 + probability = 0.45 [[recipe.outputs]] - item = "circuit_board" + item = "copper_ingot" amount = 1 - probability = 0.3 + probability = 0.25 + + [[recipe.outputs]] + item = "titanium_ingot" + amount = 1 + probability = 0.15 [[recipe.outputs]] item = "advanced_alloy" amount = 1 - probability = 0.1 - \ No newline at end of file + probability = 0.15 + +# ----------------------------------------------------------------------------- +# Basic components (tier 2, early game) +# ----------------------------------------------------------------------------- + +[[recipe]] +id = "copper_wire" +unlock_at_station_level = -1 +building = "assembler" +inputs = [{item = "copper_ingot", amount = 1}] +outputs = [{item = "copper_wire", amount = 2}] +duration_seconds = 1.5 + +[[recipe]] +id = "steel_plate" +unlock_at_station_level = -1 +building = "assembler" +inputs = [{item = "iron_ingot", amount = 2}] +outputs = [{item = "steel_plate", amount = 1}] +duration_seconds = 2.0 + +[[recipe]] +id = "circuit_board" +unlock_at_station_level = -1 +building = "assembler" +inputs = [{item = "iron_ingot", amount = 1}, {item = "copper_wire", amount = 2}] +outputs = [{item = "circuit_board", amount = 1}] +duration_seconds = 2.0 + +[[recipe]] +id = "building_blocks" +unlock_at_station_level = -1 +building = "assembler" +inputs = [{item = "iron_ingot", amount = 4}] +outputs = [{item = "building_block", amount = 10}] +duration_seconds = 4.0 + +# ----------------------------------------------------------------------------- +# Advanced components (tier 3, midgame) +# ----------------------------------------------------------------------------- + +[[recipe]] +id = "mechanical_parts" +unlock_at_station_level = -1 +building = "assembler" +inputs = [{item = "steel_plate", amount = 1}, {item = "iron_ingot", amount = 1}] +outputs = [{item = "mechanical_parts", amount = 2}] +duration_seconds = 2.5 + +[[recipe]] +id = "targeting_unit" +unlock_at_station_level = -1 +building = "assembler" +inputs = [{item = "circuit_board", amount = 2}, {item = "copper_wire", amount = 1}] +outputs = [{item = "targeting_unit", amount = 1}] +duration_seconds = 3.0 + +[[recipe]] +id = "drive_unit" +unlock_at_station_level = -1 +building = "assembler" +inputs = [ + {item = "steel_plate", amount = 1}, + {item = "mechanical_parts", amount = 1}, + {item = "circuit_board", amount = 1}, +] +outputs = [{item = "drive_unit", amount = 1}] +duration_seconds = 4.0 + +[[recipe]] +id = "titanium_frame" +unlock_at_station_level = -1 +building = "assembler" +inputs = [{item = "titanium_ingot", amount = 2}, {item = "steel_plate", amount = 1}] +outputs = [{item = "titanium_frame", amount = 1}] +duration_seconds = 4.0 + +# ----------------------------------------------------------------------------- +# Capital components (tier 4, lategame — gated on advanced_alloy) +# ----------------------------------------------------------------------------- + +[[recipe]] +id = "reinforced_plating" +unlock_at_station_level = -1 +building = "assembler" +inputs = [{item = "steel_plate", amount = 2}, {item = "advanced_alloy", amount = 1}] +outputs = [{item = "reinforced_plating", amount = 1}] +duration_seconds = 5.0 + +[[recipe]] +id = "capital_core" +unlock_at_station_level = -1 +building = "assembler" +inputs = [ + {item = "targeting_unit", amount = 1}, + {item = "drive_unit", amount = 1}, + {item = "advanced_alloy", amount = 2}, +] +outputs = [{item = "capital_core", amount = 1}] +duration_seconds = 8.0 + +# ----------------------------------------------------------------------------- +# Module items — early game +# ----------------------------------------------------------------------------- + +[[recipe]] +id = "laser_cannon_s_module" +unlock_at_station_level = -1 +building = "assembler" +inputs = [{item = "iron_ingot", amount = 2}, {item = "circuit_board", amount = 1}] +outputs = [{item = "laser_cannon_s_module", amount = 1}] +duration_seconds = 3.0 + +[[recipe]] +id = "salvager_module" +unlock_at_station_level = -1 +building = "assembler" +inputs = [{item = "steel_plate", amount = 1}, {item = "circuit_board", amount = 1}] +outputs = [{item = "salvager_module", amount = 1}] +duration_seconds = 4.0 + +[[recipe]] +id = "repair_tool_module" +unlock_at_station_level = -1 +building = "assembler" +inputs = [{item = "circuit_board", amount = 2}, {item = "copper_wire", amount = 1}] +outputs = [{item = "repair_tool_module", amount = 1}] +duration_seconds = 4.0 + +[[recipe]] +id = "armor_plates_module" +unlock_at_station_level = -1 +building = "assembler" +inputs = [{item = "steel_plate", amount = 2}] +outputs = [{item = "armor_plates_module", amount = 1}] +duration_seconds = 3.0 + +[[recipe]] +id = "sensor_booster_module" +unlock_at_station_level = -1 +building = "assembler" +inputs = [{item = "circuit_board", amount = 1}, {item = "copper_wire", amount = 2}] +outputs = [{item = "sensor_booster_module", amount = 1}] +duration_seconds = 3.0 + +[[recipe]] +id = "maneuvering_thrusters_module" +unlock_at_station_level = -1 +building = "assembler" +inputs = [{item = "mechanical_parts", amount = 1}, {item = "copper_wire", amount = 1}] +outputs = [{item = "maneuvering_thrusters_module", amount = 1}] +duration_seconds = 3.0 + +# ----------------------------------------------------------------------------- +# Module items — midgame +# ----------------------------------------------------------------------------- + +[[recipe]] +id = "afterburner_module" +unlock_at_station_level = -1 +building = "assembler" +inputs = [{item = "drive_unit", amount = 1}, {item = "steel_plate", amount = 1}] +outputs = [{item = "afterburner_module", amount = 1}] +duration_seconds = 4.0 + +[[recipe]] +id = "weapon_upgrade_module" +unlock_at_station_level = -1 +building = "assembler" +inputs = [{item = "targeting_unit", amount = 1}, {item = "steel_plate", amount = 1}] +outputs = [{item = "weapon_upgrade_module", amount = 1}] +duration_seconds = 4.0 + +[[recipe]] +id = "weapon_primer_module" +unlock_at_station_level = -1 +building = "assembler" +inputs = [{item = "targeting_unit", amount = 1}, {item = "copper_wire", amount = 2}] +outputs = [{item = "weapon_primer_module", amount = 1}] +duration_seconds = 4.0 + +[[recipe]] +id = "weapon_stabilizer_module" +unlock_at_station_level = -1 +building = "assembler" +inputs = [{item = "targeting_unit", amount = 1}, {item = "mechanical_parts", amount = 1}] +outputs = [{item = "weapon_stabilizer_module", amount = 1}] +duration_seconds = 4.0 + +[[recipe]] +id = "laser_cannon_m_module" +unlock_at_station_level = -1 +building = "assembler" +inputs = [{item = "targeting_unit", amount = 1}, {item = "titanium_frame", amount = 1}] +outputs = [{item = "laser_cannon_m_module", amount = 1}] +duration_seconds = 6.0 + +[[recipe]] +id = "drone_bay_module" +unlock_at_station_level = -1 +building = "assembler" +inputs = [ + {item = "titanium_frame", amount = 1}, + {item = "mechanical_parts", amount = 1}, + {item = "circuit_board", amount = 1}, +] +outputs = [{item = "drone_bay_module", amount = 1}] +duration_seconds = 6.0 + +# ----------------------------------------------------------------------------- +# Module items — lategame +# ----------------------------------------------------------------------------- + +[[recipe]] +id = "laser_cannon_l_module" +unlock_at_station_level = -1 +building = "assembler" +inputs = [ + {item = "targeting_unit", amount = 2}, + {item = "reinforced_plating", amount = 2}, + {item = "titanium_frame", amount = 1}, +] +outputs = [{item = "laser_cannon_l_module", amount = 1}] +duration_seconds = 12.0 + +[[recipe]] +id = "drone_hangar_module" +unlock_at_station_level = -1 +building = "assembler" +inputs = [ + {item = "capital_core", amount = 1}, + {item = "titanium_frame", amount = 2}, + {item = "reinforced_plating", amount = 2}, +] +outputs = [{item = "drone_hangar_module", amount = 1}] +duration_seconds = 20.0 + +# ----------------------------------------------------------------------------- +# Ship hulls +# ----------------------------------------------------------------------------- + +[[recipe]] +id = "drone_hull" +unlock_at_station_level = -1 +building = "assembler" +inputs = [{item = "iron_ingot", amount = 5}, {item = "circuit_board", amount = 1}] +outputs = [{item = "drone_hull", amount = 1}] +duration_seconds = 4.0 + +[[recipe]] +id = "frigate_hull" +unlock_at_station_level = -1 +building = "assembler" +inputs = [ + {item = "steel_plate", amount = 3}, + {item = "mechanical_parts", amount = 1}, + {item = "circuit_board", amount = 1}, +] +outputs = [{item = "frigate_hull", amount = 1}] +duration_seconds = 8.0 + +[[recipe]] +id = "destroyer_hull" +unlock_at_station_level = -1 +building = "assembler" +inputs = [ + {item = "steel_plate", amount = 5}, + {item = "mechanical_parts", amount = 2}, + {item = "circuit_board", amount = 1}, +] +outputs = [{item = "destroyer_hull", amount = 1}] +duration_seconds = 10.0 + +[[recipe]] +id = "cruiser_hull" +unlock_at_station_level = -1 +building = "assembler" +inputs = [ + {item = "titanium_frame", amount = 2}, + {item = "steel_plate", amount = 4}, + {item = "drive_unit", amount = 1}, +] +outputs = [{item = "cruiser_hull", amount = 1}] +duration_seconds = 15.0 + +[[recipe]] +id = "battlecruiser_hull" +unlock_at_station_level = -1 +building = "assembler" +inputs = [ + {item = "titanium_frame", amount = 3}, + {item = "steel_plate", amount = 6}, + {item = "drive_unit", amount = 1}, + {item = "targeting_unit", amount = 1}, +] +outputs = [{item = "battlecruiser_hull", amount = 1}] +duration_seconds = 20.0 + +[[recipe]] +id = "battleship_hull" +unlock_at_station_level = -1 +building = "assembler" +inputs = [ + {item = "titanium_frame", amount = 4}, + {item = "reinforced_plating", amount = 2}, + {item = "drive_unit", amount = 2}, +] +outputs = [{item = "battleship_hull", amount = 1}] +duration_seconds = 30.0 + +[[recipe]] +id = "dreadnought_hull" +unlock_at_station_level = -1 +building = "assembler" +inputs = [ + {item = "capital_core", amount = 1}, + {item = "titanium_frame", amount = 6}, + {item = "reinforced_plating", amount = 4}, + {item = "drive_unit", amount = 2}, +] +outputs = [{item = "dreadnought_hull", amount = 1}] +duration_seconds = 60.0 + +[[recipe]] +id = "carrier_hull" +unlock_at_station_level = -1 +building = "assembler" +inputs = [ + {item = "capital_core", amount = 1}, + {item = "titanium_frame", amount = 5}, + {item = "reinforced_plating", amount = 3}, + {item = "drive_unit", amount = 2}, +] +outputs = [{item = "carrier_hull", amount = 1}] +duration_seconds = 60.0 diff --git a/bin/app/data/config/visuals.toml b/bin/app/data/config/visuals.toml index bee82dd..0a59ecf 100644 --- a/bin/app/data/config/visuals.toml +++ b/bin/app/data/config/visuals.toml @@ -106,6 +106,8 @@ glyph = "E" # drawn around it. One section per ItemType. # ----------------------------------------------------------------------------- +# --- ores --- + [items.iron_ore] fill = "#8a5a4a" outline = "#201010" @@ -114,6 +116,12 @@ outline = "#201010" fill = "#c47a3a" outline = "#3a1a0a" +[items.titanium_ore] +fill = "#9aa3ad" +outline = "#2a2e33" + +# --- ingots --- + [items.iron_ingot] fill = "#b0b0b8" outline = "#202028" @@ -122,34 +130,80 @@ outline = "#202028" fill = "#d48a4a" outline = "#402010" -[items.circuit_board] -fill = "#2ea35a" -outline = "#0a2a14" +[items.titanium_ingot] +fill = "#c8d2dc" +outline = "#3a4048" -[items.advanced_alloy] -fill = "#a06acc" -outline = "#201030" - -[items.building_block] -fill = "#c8b070" -outline = "#302810" +# --- salvage loop --- [items.scrap] fill = "#7a7268" outline = "#201a14" -[items.drone_hull] -fill = "#1b1b1b" -outline = "#1402b3" +[items.advanced_alloy] +fill = "#a06acc" +outline = "#201030" -[items.laser_cannon_xs_module] -fill = "#691313" -outline = "#f3ff4f" +# --- basic components --- + +[items.copper_wire] +fill = "#e09a50" +outline = "#3a2008" + +[items.steel_plate] +fill = "#8a92a0" +outline = "#22262c" + +[items.circuit_board] +fill = "#2ea35a" +outline = "#0a2a14" + +[items.building_block] +fill = "#c8b070" +outline = "#302810" + +# --- advanced components --- + +[items.mechanical_parts] +fill = "#6f7a66" +outline = "#1c2018" + +[items.targeting_unit] +fill = "#3a9e8c" +outline = "#0c2824" + +[items.drive_unit] +fill = "#4a6ad0" +outline = "#101a38" + +[items.titanium_frame] +fill = "#b8c4d4" +outline = "#343c48" + +# --- capital components --- + +[items.reinforced_plating] +fill = "#8a6ad0" +outline = "#1c1038" + +[items.capital_core] +fill = "#b040d0" +outline = "#280c30" + +# --- module items --- [items.laser_cannon_s_module] fill = "#691313" outline = "#f3ff4f" +[items.laser_cannon_m_module] +fill = "#892020" +outline = "#f3ff4f" + +[items.laser_cannon_l_module] +fill = "#a92d2d" +outline = "#f3ff4f" + [items.salvager_module] fill = "#b2cfdd" outline = "#236137" @@ -158,6 +212,76 @@ outline = "#236137" fill = "#2e9ba3" outline = "#689275" +[items.armor_plates_module] +fill = "#808080" +outline = "#202020" + +[items.sensor_booster_module] +fill = "#40a0ff" +outline = "#102840" + +[items.maneuvering_thrusters_module] +fill = "#5090e0" +outline = "#142438" + +[items.afterburner_module] +fill = "#6080c0" +outline = "#182030" + +[items.weapon_upgrade_module] +fill = "#ff4040" +outline = "#401010" + +[items.weapon_primer_module] +fill = "#e03838" +outline = "#380e0e" + +[items.weapon_stabilizer_module] +fill = "#c03030" +outline = "#300c0c" + +[items.drone_bay_module] +fill = "#cc66ff" +outline = "#331040" + +[items.drone_hangar_module] +fill = "#9933cc" +outline = "#260c33" + +# --- ship hulls (outline matches the ship's fleet color in [ships.*]) --- + +[items.drone_hull] +fill = "#1b1b1b" +outline = "#3366ff" + +[items.frigate_hull] +fill = "#1b1b1b" +outline = "#44aaff" + +[items.destroyer_hull] +fill = "#1b1b1b" +outline = "#33ccaa" + +[items.cruiser_hull] +fill = "#1b1b1b" +outline = "#66cc33" + +[items.battlecruiser_hull] +fill = "#1b1b1b" +outline = "#cccc33" + +[items.battleship_hull] +fill = "#1b1b1b" +outline = "#ff9933" + +[items.dreadnought_hull] +fill = "#1b1b1b" +outline = "#ff5533" + +[items.carrier_hull] +fill = "#1b1b1b" +outline = "#cc66ff" + # ----------------------------------------------------------------------------- # Ships # diff --git a/docs/content_design.md b/docs/content_design.md index c8c3639..ed4e49a 100644 --- a/docs/content_design.md +++ b/docs/content_design.md @@ -1,9 +1,9 @@ # Content Design — Ships & Modules -First real-content iteration (June 2026). This pass defines ship hull grids -and module surface masks only. Stats, materials, recipes, and threat costs in -the config files are placeholders; the recipe pass and the balancing pass -come later. +First real-content iterations (June 2026). Pass 1 defined ship hull grids and +module surface masks; pass 2 defined the production tree (recipes). Stats and +threat costs in the config files are still placeholders for the balancing +pass. ## Design principle: footprint gating @@ -129,6 +129,48 @@ Maximum simultaneous (disjoint) placements: m guns — cruiser 2, battlecruiser 3, battleship 4; l guns — battleship 1, dreadnought 3; drone hangar — carrier 1. +## Production tree + +Design principle: each game phase adds exactly one new base input chain, so +factory complexity ramps alongside ship size. + +| Phase | New input | How acquired | Unlocks | +|-------|-----------|--------------|---------| +| early | iron_ore, copper_ore | mined | drone, frigate, destroyer; small guns and basic supports | +| mid | titanium_ore | mined (3x slower than iron) | cruiser, battlecruiser; m guns, drone bay, weapon modifiers | +| late | advanced_alloy | ONLY from reprocessing salvaged scrap | battleship, dreadnought, carrier; l guns, drone hangar | + +The advanced_alloy gate is the core loop hook: capital ship production +requires fighting (salvaging scrap from kills and reprocessing it), not just +mining. The reprocessing plant turns 5 scrap into iron/copper/titanium ingots +or advanced_alloy probabilistically. + +Intermediate components, by tier: + +- **Tier 2 (early):** copper_wire (copper), steel_plate (iron), circuit_board + (iron + wire), building_block (iron). +- **Tier 3 (mid):** mechanical_parts (steel + iron), targeting_unit (circuits + + wire), drive_unit (steel + mechanical_parts + circuit), titanium_frame + (titanium + steel). +- **Tier 4 (late):** reinforced_plating (steel + advanced_alloy), + capital_core (targeting_unit + drive_unit + 2 advanced_alloy). + +Hulls and modules consume intermediates of their tier: early items are built +from tier-2 parts, midgame items require tier-3 parts (deeper chains, more +assemblers), capital items require tier-4 parts (and therefore combat). Hull +items are named `_hull`; module items `_module`. Every item has +an `[items.*]` entry in visuals.toml; hull item outlines match the ship's +fleet color from `[ships.*]`. + +Consistency is checked by `tools/verify_recipes.py` — re-run it after editing +recipes, ship/module materials, or visuals: + + python dota_factory/tools/verify_recipes.py + +It verifies every consumed item has a producer, every item has a visuals +entry, flags orphaned items, and prints which items are reprocessing-only +(currently exactly advanced_alloy). + ## Deliberate placeholders / open questions for later passes - All new hulls have `threat.cost_formula = "0"` so enemy waves do not spawn @@ -136,14 +178,12 @@ drone hangar — carrier 1. eligible, regardless of unlock level). The balancing pass should set real threat costs together with `default_modules` loadouts so waves spawn them armed. -- All new hulls are `unlock_at_station_level = -1` (available from the start) - to make layout testing easy; the progression pass should stagger these. -- Ship hull material items (`frigate_hull` … `carrier_hull`) and the new - module items (`laser_cannon_m_module`, `laser_cannon_l_module`, - `drone_bay_module`, `drone_hangar_module`, …) have no recipes yet — that is - the recipe pass. The old `laser_cannon_xs_module` recipe is orphaned (the - module was renamed to `laser_cannon_s`, consuming `laser_cannon_s_module`, - which already has a recipe). +- All new hulls and all assembler recipes are `unlock_at_station_level = -1` + (available from the start) to make testing easy; the balancing pass should + stagger these so mid/lategame recipes drop as schematics from enemy defence + stations. +- Recipe quantities and durations are a first guess, deliberately roughly + tiered (capital hulls ~60 s, drones 4 s); the balancing pass tunes them. - `drone_bay` and `drone_hangar` are footprint-only placeholders: the drone launching capability does not exist in the simulation yet, so they define no capability section. diff --git a/tools/verify_recipes.py b/tools/verify_recipes.py new file mode 100644 index 0000000..fbeb821 --- /dev/null +++ b/tools/verify_recipes.py @@ -0,0 +1,132 @@ +#!/usr/bin/env python3 +"""Verify the recipe tree is closed and consistent. + +Reads recipes.toml, ships.toml, modules.toml, and visuals.toml, then checks: + + 1. Producers — every item consumed anywhere (recipe inputs, ship hull + materials, module materials) is produced by some recipe. 'scrap' is + exempt: it drops from destroyed ships. + 2. Visuals — every item that exists in the economy has an [items.*] + entry in visuals.toml, and visuals.toml has no entries for items + that no longer exist. + 3. Orphans — items that are produced but never consumed (warning only; + 'building_block' is exempt: the HQ consumes it). + +It also prints which items are obtainable ONLY through reprocessing — +the combat-gated materials — so changes to that gate are visible. + +Usage (from the repository root or anywhere else): + + python dota_factory/tools/verify_recipes.py + python dota_factory/tools/verify_recipes.py --config-dir path/to/config + +By default the config directory is resolved relative to this script +(../bin/app/data/config). Requires the 'toml' package on Python < 3.11 +(pip install --user toml); on 3.11+ the standard tomllib is used. + +Exits 1 if a producer or visuals check fails, 0 otherwise (warnings do +not affect the exit code). +""" + +import argparse +import os +import sys + + +WORLD_SOURCED_ITEMS = {"scrap"} # dropped by destroyed ships +IMPLICITLY_CONSUMED_ITEMS = {"building_block"} # consumed by the HQ + + +def load_toml(path): + try: + import tomllib + with open(path, "rb") as fh: + return tomllib.load(fh) + except ImportError: + import toml + return toml.load(path) + + +def main(): + default_dir = os.path.normpath(os.path.join( + os.path.dirname(os.path.abspath(__file__)), + "..", "bin", "app", "data", "config")) + parser = argparse.ArgumentParser( + description="Check recipe tree consistency across the config files.") + parser.add_argument("--config-dir", default=default_dir, + help="directory containing the config toml files" + " (default: %(default)s)") + args = parser.parse_args() + + recipes = load_toml(os.path.join(args.config_dir, "recipes.toml"))["recipe"] + ships = load_toml(os.path.join(args.config_dir, "ships.toml"))["ship"] + modules = load_toml(os.path.join(args.config_dir, "modules.toml"))["module"] + visuals = load_toml(os.path.join(args.config_dir, "visuals.toml")) + + produced = {} # item id -> [producer descriptions] + consumed = {} # item id -> [consumer descriptions] + + for recipe in recipes: + for output in recipe.get("outputs", []): + produced.setdefault(output["item"], []).append( + "recipe '{}'".format(recipe["id"])) + for inp in recipe.get("inputs", []): + consumed.setdefault(inp["item"], []).append( + "recipe '{}'".format(recipe["id"])) + + for ship in ships: + for material in ship["schematic"]["materials"]: + consumed.setdefault(material["item"], []).append( + "ship '{}'".format(ship["id"])) + + for module in modules: + for material in module["materials"]: + consumed.setdefault(material["item"], []).append( + "module '{}'".format(module["id"])) + + all_items = set(produced) | set(consumed) | WORLD_SOURCED_ITEMS + visual_items = set(visuals.get("items", {})) + + errors = [] + warnings = [] + + for item in sorted(consumed): + if item not in produced and item not in WORLD_SOURCED_ITEMS: + errors.append("no producer for '{}' (consumed by {})".format( + item, ", ".join(sorted(set(consumed[item]))))) + + for item in sorted(all_items - visual_items): + errors.append("no [items.{}] entry in visuals.toml".format(item)) + for item in sorted(visual_items - all_items): + warnings.append("visuals.toml entry [items.{}] matches no known item" + .format(item)) + + for item in sorted(produced): + if item not in consumed and item not in IMPLICITLY_CONSUMED_ITEMS: + warnings.append("'{}' is produced but never consumed (by {})" + .format(item, ", ".join(sorted(set(produced[item]))))) + + reprocessing_only = sorted( + item for item, producers in produced.items() + if all("reprocessing" in p for p in producers)) + + print("{} items, {} recipes, {} ships, {} modules".format( + len(all_items), len(recipes), len(ships), len(modules))) + print("obtainable only via reprocessing: {}".format( + ", ".join(reprocessing_only) if reprocessing_only else "(none)")) + print() + + for warning in warnings: + print("WARNING: {}".format(warning)) + for error in errors: + print("ERROR: {}".format(error)) + if not errors and not warnings: + print("all checks passed") + elif not errors: + print("no errors") + + return 1 if errors else 0 + + +if __name__ == "__main__": + sys.exit(main())