Files
dota_factory/tools/threat_report.py
2026-07-08 20:27:25 +02:00

263 lines
10 KiB
Python

#!/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}
# All eligible (recipe, output) pairs per item: the max rule requires
# committing an item only once EVERY eligible recipe for it is
# computable — otherwise a shallow shortcut recipe that resolves one
# pass earlier than the base path would win and underprice the item.
recipes_per_item = {}
for recipe in non_repro:
for output in recipe.get("outputs", []):
if eligible(recipe, output):
recipes_per_item.setdefault(output["item"], []).append(
(recipe, output))
def resolve_pass(require_all_recipes=True):
progress = False
best = {}
for item, pairs in recipes_per_item.items():
if item in item_threat:
continue
threats = [recipe_threat_per_unit(r, o, item_threat)
for r, o in pairs]
if require_all_recipes and any(t is None for t in threats):
continue
threats = [t for t in threats if t is not None]
if threats:
best[item] = max(threats)
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
# Deadlock guard: if recipe cycles keep items waiting on each other,
# fall back to committing with the computable subset of recipes.
progress = True
while progress:
progress = resolve_pass(require_all_recipes=False)
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())