Files
dota_factory/tools/threat_report.py
Malte Langkabel 9c275e283c give every recipe one shape: a list of output groups
Implements REQ-MAT-OUTPUT-GROUP. A recipe had two shapes -- outputs produced
together, or outputs of which exactly one happened -- and every rule over them
was written twice, selected by `building == ReprocessingPlant`: sizing a
buffer, deciding whether a cycle fits, resolving what a cycle makes, costing an
item. RecipeDef now holds output groups, each a weight and a list of items, and
a cycle yields exactly one group. One group is the ordinary recipe, so the old
two cases are the same shape with one and with several, and all four rules
collapse to one expression apiece with no building-type test left.

rollReprocessingOutput becomes rollOutputGroup, where a single group returns
without drawing or testing eligibility. That early-out is load-bearing twice
over. Drawing there would consume entropy for every ordinary recipe and shift
every later random outcome; and eligibility must not apply either, since
implicit unlocking is demand-derived, so an ordinary recipe's output can be
producible while nothing yet calls for it -- testing it would stop the building
producing rather than gate a drop. Past the early-out a group is eligible only
when all of its items are unlocked, being produced whole.

Threat follows the recipe's shape rather than the building, and the per-unit
value now divides by the group's amount as well as its odds. That moves no
number today: every item resolved through this path has amount 1, which is why
the threat expectations are untouched.

Config keeps `outputs = [...]` as the single-group form, so only the two
reprocessing recipes change shape. The recipe summary gains "/" between groups
and keeps "+" within one, which also fixes the plant reading as though a cycle
produced all of its items at once.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ne3mejABZoLWKLh8fgpM3x
2026-08-17 12:47:09 +02:00

290 lines
12 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 semantics described above — per-unit division, scrap fallback
rule, fixpoint resolution including reprocessing, and the commit-all-
recipes max rule — are implemented in both this script and in
src/lib/sim/ThreatCostCalculator.cpp. The two should produce identical
values for any given config.
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 output_groups(recipe):
"""The recipe's output groups, whichever form the config writes them in.
`outputs = [...]` is the single-group shorthand; `[[recipe.output_group]]` is the
several-group form (REQ-MAT-OUTPUT-GROUP). A cycle yields exactly one group.
"""
if "output_group" in recipe:
return recipe["output_group"]
return [{"items": recipe.get("outputs", [])}]
def picks_one_of_several(recipe):
"""True when a cycle picks between groups, which is what makes a yield random."""
return len(output_groups(recipe)) > 1
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."""
# What decides the model is the recipe's shape, not the building running it: a recipe
# picking between groups costs its items by their odds, one yielding a single group
# every cycle costs them outright (REQ-MAT-OUTPUT-GROUP, REQ-THREAT-ITEM).
non_repro = [r for r in recipes if not picks_one_of_several(r)]
repro = [r for r in recipes if picks_one_of_several(r)]
# 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 output_groups(recipe)[0]["items"]:
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 output_groups(recipe)[0]["items"]:
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", []))
groups = output_groups(recipe)
total_weight = sum(g.get("probability", 1.0) for g in groups)
for group in groups:
probability = group.get("probability", 1.0) / total_weight
if probability <= 0.0:
continue
for output in group["items"]:
# This model 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
# Per unit: the cycle's cost over the odds of getting this group at
# all, then over how many units the group yields.
divisor = probability * output["amount"]
if divisor <= 0.0:
continue
item_threat[output["item"]] = (
(scrap_threat * scrap_per_cycle
+ recipe["duration_seconds"]) / divisor)
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:
# A recipe that picks between groups has no steady per-item rate to quote.
if picks_one_of_several(recipe):
continue
for output in output_groups(recipe)[0]["items"]:
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())