Fix ThreatCostCalculator: per-unit division, scrap fallback, fixpoint, staggered-recipe max (action items 6-9)

Four algorithm fixes to bring ThreatCostCalculator.cpp into agreement with
tools/threat_report.py and the newly amended REQ-THREAT-ITEM semantics:

6. Scrap-consuming recipes as threat fallback only. Non-reprocessing recipes
   that take scrap as an input are excluded from an item's threat computation
   whenever at least one scrap-free recipe (miner/smelter/assembler) produces
   that item. Previously the scrap_smelting recipe (1 scrap → 1 iron_ingot)
   would have inflated iron_ingot's threat via the max rule.

7. Per-unit item threat. computeRecipeThreatPerUnit() now divides by the
   recipe's output amount, so multi-output recipes price each unit correctly.
   Example: copper_wire (1 copper_ingot, 1 s, output 2) is now 1.5, not 3.

8. Fixpoint resolution. The resolution loop now alternates the non-reprocessing
   pass and the reprocessing pass until neither makes progress, rather than
   running the reprocessing pass once at the end. Items downstream of
   reprocessing-only items (voidsteel_plate, capital_core, capital hulls,
   drone_hangar_module) now resolve correctly.

9. Max rule across staggered recipes. An item is committed only once every
   eligible recipe producing it is computable, so a shallow shortcut recipe
   (e.g. shortcut_steel_plate: 3 iron_ore → 1 steel_plate, resolvable one
   iteration earlier) cannot undercut the expensive base path. A deadlock
   fallback (require_all_recipes=False) handles potential recipe cycles.

docs/requirements.md: REQ-THREAT-ITEM amended for per-unit division, the
scrap-fallback rule, and order-independence via fixpoint.

docs/progression_design.md: action items 6-9 removed (completed); remaining
items 1-5 renumbered unchanged.

tools/threat_report.py: NOTE updated — C++ now matches Python semantics.

bin/test/data/config/recipes.toml: four minimal test recipes added (one per
fix: scrap_iron, dual_wire, downstream_product, staggered_item_{cheap,expensive}).

src/test/ThreatCostCalculatorTest.cpp: four new TEST_CASEs covering each fix.

Expected values with the live config (bin/app/data/config) verified by
threat_report.py: iron_ingot 2, copper_wire 1.5, steel_plate 7, control_chip
12, voidsteel_plate 141, capital_core 240; fitted ships 10.5/47/99/233.5/
354.5/722.5/1491.5/1436.5. All 378 test cases pass.
This commit is contained in:
2026-07-03 18:45:39 +02:00
parent d889b79658
commit 38fd2e4e89
6 changed files with 333 additions and 98 deletions

View File

@@ -107,3 +107,62 @@ TEST_CASE("ThreatCostCalculator: unknown ship returns zero", "[threat]")
double threat = calculateShipThreatCost(table, cfg, "nonexistent_ship", {});
CHECK(threat == Approx(0.0));
}
// Fix 6: scrap-consuming recipes are a fallback only.
// iron_ingot has a scrap-free smelter recipe, so the scrap_iron recipe must
// be excluded. iron_ingot threat must not be inflated by the scrap path.
TEST_CASE("ThreatCostCalculator: scrap-consuming recipe excluded when scrap-free recipe exists", "[threat]")
{
const GameConfig cfg = loadConfig();
const ThreatCostTable& table = cfg.threatCosts;
// scrap_iron recipe: duration=1.0, 1 scrap (threat=1.0) -> 1 iron_ingot.
// That would give 1.0 + 1.0*1 = 2.0 per unit — but it must be excluded
// because the scrap-free iron_ingot smelter recipe (threat=4.0) exists.
// iron_ingot threat stays at 4.0.
CHECK(table.itemThreat.at("iron_ingot") == Approx(4.0));
// The pure reprocessing-only item (advanced_alloy) must still be resolved
// via the reprocessing path.
CHECK(table.itemThreat.count("advanced_alloy") == 1u);
}
// Fix 7: per-unit item threat divides by output amount.
// dual_wire: assembler, 1 iron_ore -> 2 dual_wire, duration 3.0.
// Per-unit threat = (3.0 + iron_ore(1.0)*1) / 2 = 4.0 / 2 = 2.0.
TEST_CASE("ThreatCostCalculator: per-unit division by output amount", "[threat]")
{
const GameConfig cfg = loadConfig();
const ThreatCostTable& table = cfg.threatCosts;
CHECK(table.itemThreat.at("dual_wire") == Approx(2.0));
}
// Fix 8: fixpoint resolution — items downstream of reprocessing-only items
// must be resolved after the reprocessing pass re-enables the non-reprocessing
// pass.
// downstream_product: assembler, 1 advanced_alloy -> 1, duration 2.0.
// advanced_alloy = 80.0 (reprocessing-only).
// downstream_product = 2.0 + 80.0*1 = 82.0.
TEST_CASE("ThreatCostCalculator: downstream-of-reprocessing item resolves via fixpoint", "[threat]")
{
const GameConfig cfg = loadConfig();
const ThreatCostTable& table = cfg.threatCosts;
CHECK(table.itemThreat.at("advanced_alloy") == Approx(80.0));
CHECK(table.itemThreat.at("downstream_product") == Approx(82.0));
}
// Fix 9: max rule across staggered recipes — item is committed only once
// every eligible recipe for it is computable.
// staggered_item has two recipes:
// cheap: 1 iron_ore (1.0) + 1.0 s = 2.0 (resolves early)
// expensive: 1 circuit_board (28.0) + 1.0 s = 29.0 (resolves later)
// expected threat = max(2.0, 29.0) = 29.0, not 2.0.
TEST_CASE("ThreatCostCalculator: staggered recipes committed only when all computable", "[threat]")
{
const GameConfig cfg = loadConfig();
const ThreatCostTable& table = cfg.threatCosts;
CHECK(table.itemThreat.at("staggered_item") == Approx(29.0));
}