#include "catch.hpp" #include "ConfigLoader.h" #include "ThreatCostCalculator.h" #include "TestConfig.h" TEST_CASE("ThreatCostCalculator: miner item threat equals duration", "[threat]") { const GameConfig cfg = loadTestConfig(); const ThreatCostTable& table = cfg.threatCosts; CHECK(table.itemThreat.at("iron_ore") == Approx(1.0)); CHECK(table.itemThreat.at("copper_ore") == Approx(1.5)); } TEST_CASE("ThreatCostCalculator: smelter item threat includes input costs", "[threat]") { const GameConfig cfg = loadTestConfig(); const ThreatCostTable& table = cfg.threatCosts; // iron_ingot: duration 2.0 + iron_ore(1.0) * 2 = 4.0 CHECK(table.itemThreat.at("iron_ingot") == Approx(4.0)); // copper_ingot: duration 2.5 + copper_ore(1.5) * 2 = 5.5 CHECK(table.itemThreat.at("copper_ingot") == Approx(5.5)); } TEST_CASE("ThreatCostCalculator: assembler takes max across recipes", "[threat]") { const GameConfig cfg = loadTestConfig(); const ThreatCostTable& table = cfg.threatCosts; // circuit_board has three non-reprocessing recipes: // 5.0 + iron_ingot(4.0)*3 + copper_ingot(5.5)*2 = 28.0 // 3.0 + copper_ingot(5.5)*3 = 19.5 // 6.0 + iron_ingot(4.0)*5 = 26.0 // max = 28.0 CHECK(table.itemThreat.at("circuit_board") == Approx(28.0)); } TEST_CASE("ThreatCostCalculator: scrap threat is 1 / scrap_per_threat", "[threat]") { const GameConfig cfg = loadTestConfig(); const ThreatCostTable& table = cfg.threatCosts; // REQ-THREAT-SCRAP: scrap threat is the constant 1 / world.scrap_per_threat. // The test config sets scrap_per_threat = 1.0, so scrapThreat = 1.0. CHECK(table.scrapThreat == Approx(1.0 / cfg.world.scrapPerThreat)); CHECK(table.scrapThreat == Approx(1.0)); } TEST_CASE("ThreatCostCalculator: reprocessing-only item threat", "[threat]") { const GameConfig cfg = loadTestConfig(); const ThreatCostTable& table = cfg.threatCosts; // advanced_alloy: reprocessing recipe with scrap*5, duration 3.0, probability 0.1 // scrapThreat = 1.0 (= 1 / scrap_per_threat), so (1.0 * 5 + 3.0) / 0.1 = 80.0 CHECK(table.itemThreat.at("advanced_alloy") == Approx(80.0)); } TEST_CASE("ThreatCostCalculator: ship threat with default modules", "[threat]") { const GameConfig cfg = loadTestConfig(); const ThreatCostTable& table = cfg.threatCosts; // interceptor: 10 + iron_ingot(4)*3 + circuit_board(28)*1 + laser_cannon(5 + 4*1) = 59.0 double interceptorThreat = calculateShipThreatCost( table, cfg, "interceptor", cfg.ships.ships[0].defaultModules); CHECK(interceptorThreat == Approx(59.0)); // salvage_ship (no default modules): 10 + iron_ingot(4)*4 = 26.0 double salvageThreat = calculateShipThreatCost( table, cfg, "salvage_ship", cfg.ships.ships[2].defaultModules); CHECK(salvageThreat == Approx(26.0)); } TEST_CASE("ThreatCostCalculator: ship threat with custom modules", "[threat]") { const GameConfig cfg = loadTestConfig(); const ThreatCostTable& table = cfg.threatCosts; // interceptor base: 10 + iron_ingot(4)*3 + circuit_board(28)*1 = 50.0 // + armor_plate: 3 + iron_ingot(4)*2 = 11.0 // + sensor_booster: 2 + circuit_board(28)*1 = 30.0 // total = 50.0 + 11.0 + 30.0 = 91.0 std::vector modules; PlacedModule armor; armor.moduleId = "armor_plate"; modules.push_back(armor); PlacedModule sensor; sensor.moduleId = "sensor_booster"; modules.push_back(sensor); double threat = calculateShipThreatCost(table, cfg, "interceptor", modules); CHECK(threat == Approx(91.0)); } TEST_CASE("ThreatCostCalculator: unknown ship returns zero", "[threat]") { const GameConfig cfg = loadTestConfig(); const ThreatCostTable& table = cfg.threatCosts; 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 = loadTestConfig(); 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 = loadTestConfig(); 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 = loadTestConfig(); 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 = loadTestConfig(); const ThreatCostTable& table = cfg.threatCosts; CHECK(table.itemThreat.at("staggered_item") == Approx(29.0)); }