Add tools/threat_report.py (action item 7)
Reads recipes/ships/modules/world.toml and reports per-item threat values, module contributions, hull-only and fitted ship threats (default_modules), producer:consumer ratio tables, and belt feasibility against the single-belt cap. Implements the agreed design semantics: per-unit threat (recipe threat divided by output amount), the scrap-fallback rule, and fixpoint resolution through reprocessing-only items. Running it against the current configs surfaced two ThreatCostCalculator deviations, recorded as new action items: multi-output recipes are double-priced (no per-unit division), and items downstream of reprocessing-only items never resolve, underestimating capital hull threat. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DyCu8vwChKMbLJQ3xosYEN
This commit is contained in:
@@ -341,10 +341,11 @@ prints per-item threat values and producer:consumer ratio tables.
|
|||||||
|
|
||||||
### Numbers — first pass
|
### Numbers — first pass
|
||||||
|
|
||||||
Computed with a recursive threat calculator (to be ported to
|
Computed with a recursive threat calculator (now available as
|
||||||
`tools/threat_report.py`, see the action items in
|
`tools/threat_report.py`, which reads the real config files — it
|
||||||
`progression_design.md`); quantities and durations tuned so fitted
|
verifies these numbers once the v2 tree lands in the configs);
|
||||||
ships land on the threat-cost ladder and the ratio curve is realized.
|
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
|
**Economy constants:** `scrap_per_threat = 0.25` (1 scrap per 4 threat
|
||||||
destroyed — a cruiser kill drops ~59 scrap). Reprocessing: 4 scrap per
|
destroyed — a cruiser kill drops ~59 scrap). Reprocessing: 4 scrap per
|
||||||
|
|||||||
@@ -380,9 +380,15 @@ in `requirements.md` and the git history). Still open:
|
|||||||
rule for the reprocessing path. Otherwise the scrap→ingot smelter
|
rule for the reprocessing path. Otherwise the scrap→ingot smelter
|
||||||
recipe would inflate the basic materials' threat via the
|
recipe would inflate the basic materials' threat via the
|
||||||
max-across-recipes rule, poisoning every downstream value.
|
max-across-recipes rule, poisoning every downstream value.
|
||||||
7. **Threat report tool.** Port the numbers-pass calculator to
|
7. **Per-unit item threat.** Amend REQ-THREAT-ITEM and
|
||||||
`tools/threat_report.py`: per-item threat values, module
|
`ThreatCostCalculator`: a recipe's threat is divided by its output
|
||||||
contributions, fitted ship threats vs. the ladder targets,
|
amount, so item threat is production-seconds *per unit*. Currently a
|
||||||
producer:consumer ratio tables, and belt-feasibility checks, read
|
recipe producing 2 copper_wire per run assigns each wire the full
|
||||||
from the real config files. Re-run after any recipe or material change, like
|
run's threat, double-pricing multi-output items and everything
|
||||||
`verify_recipes.py`.
|
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).
|
||||||
|
|||||||
248
tools/threat_report.py
Normal file
248
tools/threat_report.py
Normal file
@@ -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())
|
||||||
Reference in New Issue
Block a user