diff --git a/docs/content_design.md b/docs/content_design.md index 95cc893..7d0ff1d 100644 --- a/docs/content_design.md +++ b/docs/content_design.md @@ -341,10 +341,11 @@ prints per-item threat values and producer:consumer ratio tables. ### Numbers — first pass -Computed with a recursive threat calculator (to be ported to -`tools/threat_report.py`, see the action items in -`progression_design.md`); quantities and durations tuned so fitted -ships land on the threat-cost ladder and the ratio curve is realized. +Computed with a recursive threat calculator (now available as +`tools/threat_report.py`, which reads the real config files — it +verifies these numbers once the v2 tree lands in the configs); +quantities and durations tuned so fitted ships land on the threat-cost +ladder and the ratio curve is realized. **Economy constants:** `scrap_per_threat = 0.25` (1 scrap per 4 threat destroyed — a cruiser kill drops ~59 scrap). Reprocessing: 4 scrap per diff --git a/docs/progression_design.md b/docs/progression_design.md index c41bdee..b96d972 100644 --- a/docs/progression_design.md +++ b/docs/progression_design.md @@ -380,9 +380,15 @@ in `requirements.md` and the git history). Still open: rule for the reprocessing path. Otherwise the scrap→ingot smelter recipe would inflate the basic materials' threat via the max-across-recipes rule, poisoning every downstream value. -7. **Threat report tool.** Port the numbers-pass calculator to - `tools/threat_report.py`: per-item threat values, module - contributions, fitted ship threats vs. the ladder targets, - producer:consumer ratio tables, and belt-feasibility checks, read - from the real config files. Re-run after any recipe or material change, like - `verify_recipes.py`. +7. **Per-unit item threat.** Amend REQ-THREAT-ITEM and + `ThreatCostCalculator`: a recipe's threat is divided by its output + amount, so item threat is production-seconds *per unit*. Currently a + recipe producing 2 copper_wire per run assigns each wire the full + run's threat, double-pricing multi-output items and everything + downstream of them. +8. **Fixpoint resolution in ThreatCostCalculator.** Items downstream of + reprocessing-only items (e.g. capital parts built from the scrap-only + input) never resolve, because resolution stops after the reprocessing + pass instead of iterating; their consumers silently drop the missing + materials, so capital hull threat is currently underestimated (found + by `tools/threat_report.py`, which implements the correct fixpoint). diff --git a/tools/threat_report.py b/tools/threat_report.py new file mode 100644 index 0000000..e176779 --- /dev/null +++ b/tools/threat_report.py @@ -0,0 +1,248 @@ +#!/usr/bin/env python3 +"""Report threat values, ship costs, ratios, and belt feasibility. + +Reads recipes.toml, ships.toml, modules.toml, and world.toml and prints: + + 1. Item threats — production-seconds per item unit, resolved + recursively over the recipe tree (REQ-THREAT-ITEM): + - non-reprocessing recipe: (duration + sum(input threat * qty)) + divided by the output amount, so threat is per unit; + - multiple recipes: the maximum across them; + - recipes consuming scrap participate only if no scrap-free + recipe produces the item (fallback rule); + - reprocessing-only items: (scrap threat * scrap per cycle + + duration) / normalized probability (REQ-THREAT-ITEM). + Scrap threat is the constant 1 / world.scrap_per_threat + (REQ-THREAT-SCRAP). + 2. Module contributions — material threat + production time per + module instance (the amount a module adds to a ship's threat). + 3. Ship threats — hull-only and fitted with default_modules (the + loadout enemy waves spawn with, REQ-WAV-DEFAULT-MODULES). + 4. Producer:consumer ratios — buildings of the input recipe needed + per building of the consuming recipe at 100% throughput. + 5. Belt feasibility — input demand in items/s per building vs. the + single-belt cap (belt_speed_mps / tile_size_m, in items/s). + +NOTE: the per-unit division (1) and the scrap fallback rule are the +agreed design semantics (see docs/progression_design.md action items); +src/lib/sim/ThreatCostCalculator.cpp does not implement them yet. Until +it does, this report is the design reference, not a mirror of the game. + +Usage (from the repository root or anywhere else): + + python dota_factory/tools/threat_report.py + python dota_factory/tools/threat_report.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. +""" + +import argparse +import os +import sys + + +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 consumes_scrap(recipe): + return any(inp["item"] == "scrap" for inp in recipe.get("inputs", [])) + + +def recipe_threat_per_unit(recipe, output, item_threat): + threat = recipe["duration_seconds"] + for inp in recipe.get("inputs", []): + if inp["item"] not in item_threat: + return None + threat += item_threat[inp["item"]] * inp["amount"] + return threat / output["amount"] + + +def resolve_items(recipes, scrap_threat): + """Return {item: threat} resolved per REQ-THREAT-ITEM.""" + non_repro = [r for r in recipes if r["building"] != "reprocessing_plant"] + repro = [r for r in recipes if r["building"] == "reprocessing_plant"] + + # Items with at least one scrap-free producer: their scrap-consuming + # recipes never participate (fallback rule). + scrap_free_items = set() + for recipe in non_repro: + if not consumes_scrap(recipe): + for output in recipe.get("outputs", []): + scrap_free_items.add(output["item"]) + + def eligible(recipe, output): + if consumes_scrap(recipe) and output["item"] in scrap_free_items: + return False + return True + + item_threat = {"scrap": scrap_threat} + + def resolve_pass(): + progress = False + best = {} + for recipe in non_repro: + for output in recipe.get("outputs", []): + if output["item"] in item_threat: + continue + if not eligible(recipe, output): + continue + threat = recipe_threat_per_unit(recipe, output, item_threat) + if threat is None: + continue + key = output["item"] + if key not in best or threat > best[key]: + best[key] = threat + # A full sweep before committing keeps the max rule correct when + # several recipes for the same item resolve in the same pass. + for item, threat in best.items(): + item_threat[item] = threat + progress = True + return progress + + def repro_pass(): + progress = False + for recipe in repro: + scrap_per_cycle = sum(inp["amount"] + for inp in recipe.get("inputs", [])) + total_weight = sum(out.get("probability", 1.0) + for out in recipe.get("outputs", [])) + for output in recipe.get("outputs", []): + # Reprocessing defines an item's threat only when nothing + # else produces it (REQ-THREAT-ITEM). + if output["item"] in item_threat: + continue + if output["item"] in scrap_free_items: + continue + probability = output.get("probability", 1.0) / total_weight + if probability <= 0.0: + continue + item_threat[output["item"]] = ( + (scrap_threat * scrap_per_cycle + + recipe["duration_seconds"]) / probability) + progress = True + return progress + + # Iterate to a fixpoint: items downstream of reprocessing-only items + # (e.g. capital parts built from voidsteel) need another recipe pass + # after the reprocessing pass has resolved their inputs. + progress = True + while progress: + progress = resolve_pass() + progress = repro_pass() or progress + + return item_threat + + +def material_threat(materials, item_threat, missing): + total = 0.0 + for material in materials: + if material["item"] in item_threat: + total += item_threat[material["item"]] * material["amount"] + else: + missing.add(material["item"]) + return total + + +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="Report threat values, ship costs, ratios, and belt" + " feasibility from 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"] + world = load_toml(os.path.join(args.config_dir, "world.toml"))["world"] + + scrap_per_threat = world.get("scrap_per_threat", 0.0) + scrap_threat = 1.0 / scrap_per_threat if scrap_per_threat > 0 else 0.0 + belt_cap = world["belt_speed_mps"] / world["tile_size_m"] # items/s + + item_threat = resolve_items(recipes, scrap_threat) + missing = set() + + print("scrap_per_threat = {} => threat(scrap) = {:.2f}".format( + scrap_per_threat, scrap_threat)) + print() + + print("== item threats (production-seconds per unit) ==") + for item in sorted(item_threat): + if item != "scrap": + print(" {:24s} {:10.2f}".format(item, item_threat[item])) + + print() + print("== module contributions (materials + production time) ==") + module_contribution = {} + for module in modules: + contribution = (material_threat(module["materials"], item_threat, missing) + + module["production_time_seconds"]) + module_contribution[module["id"]] = contribution + print(" {:24s} {:10.2f}".format(module["id"], contribution)) + + print() + print("== ship threats (hull-only / fitted with default_modules) ==") + for ship in ships: + hull = (material_threat(ship["schematic"]["materials"], item_threat, missing) + + ship["schematic"]["production_time_seconds"]) + fitted = hull + for placed in ship.get("default_modules", []): + fitted += module_contribution.get(placed["type"], 0.0) + print(" {:16s} hull {:9.2f} fitted {:9.2f}".format( + ship["id"], hull, fitted)) + + print() + print("== producer:consumer ratios (producing buildings per consuming" + " building) ==") + producers = {} # item -> [(recipe id, items/s per building)] + for recipe in recipes: + if recipe["building"] == "reprocessing_plant": + continue + for output in recipe.get("outputs", []): + rate = output["amount"] / recipe["duration_seconds"] + producers.setdefault(output["item"], []).append((recipe["id"], rate)) + for recipe in recipes: + for inp in recipe.get("inputs", []): + need = inp["amount"] / recipe["duration_seconds"] + for producer_id, supply in producers.get(inp["item"], []): + print(" {:20s} -> {:24s} {:6.3f}".format( + producer_id, recipe["id"], need / supply)) + + print() + print("== belt feasibility (input demand items/s per building," + " cap {:.2f}/s) ==".format(belt_cap)) + over = False + for recipe in recipes: + for inp in recipe.get("inputs", []): + rate = inp["amount"] / recipe["duration_seconds"] + if rate > belt_cap: + print(" OVER: {} <- {}: {:.2f}/s".format( + recipe["id"], inp["item"], rate)) + over = True + if not over: + print(" all input demands under the single-belt cap") + + if missing: + print() + for item in sorted(missing): + print("WARNING: no threat value for material '{}'".format(item)) + + return 0 + + +if __name__ == "__main__": + sys.exit(main())