#include "ThreatCostCalculator.h" #include #include "GameConfig.h" namespace { struct RecipeRef { const RecipeDef* recipe; std::string outputItem; int outputAmount; double probability; }; double computeMaterialThreat(const ThreatCostTable& table, const std::vector& materials) { double total = 0.0; for (const RecipeIngredient& mat : materials) { std::map::const_iterator it = table.itemThreat.find(mat.item); if (it != table.itemThreat.end()) { total += it->second * mat.amount; } } return total; } // Returns true if every input of the recipe has a resolved threat value. bool allInputsResolved(const RecipeDef& recipe, const std::map& resolved) { for (const RecipeIngredient& input : recipe.inputs) { if (resolved.find(input.item) == resolved.end()) { return false; } } return true; } // Computes the raw recipe threat (duration + sum of input threats × amounts), // divided by the output amount to get the per-unit threat. double computeRecipeThreatPerUnit(const RecipeDef& recipe, int outputAmount, const std::map& resolved) { double threat = recipe.durationSeconds; for (const RecipeIngredient& input : recipe.inputs) { threat += resolved.at(input.item) * input.amount; } return threat / static_cast(outputAmount); } } // namespace ThreatCostTable computeThreatCostTable(const GameConfig& config) { ThreatCostTable table; // Scrap threat (REQ-THREAT-SCRAP) is the constant inverse of the scrap-drop // conversion (REQ-RES-DEBRIS-DROP): one scrap is worth 1 / scrap_per_threat. // Set it up front so reprocessing-only item threats (below) can use it, and so // it no longer depends on any ship's threat cost. table.scrapThreat = config.world.scrapPerThreat > 0.0 ? 1.0 / config.world.scrapPerThreat : 0.0; // ------------------------------------------------------------------------- // Build per-item recipe lookup tables. // ------------------------------------------------------------------------- // Items that have at least one non-reprocessing recipe that does NOT consume // scrap — these items' scrap-consuming recipes are excluded from threat // computation (REQ-THREAT-ITEM: scrap-consuming recipes are a fallback only). std::set scrapFreeItems; // nonReprocessingRecipes: item → all eligible non-reprocessing (recipe, output) // pairs. Scrap-consuming recipes are collected here temporarily; they are // filtered out per item after we know which items have a scrap-free producer. struct EligiblePair { const RecipeDef* recipe; int outputAmount; bool consumesScrap; }; std::map> nonReprocessingCandidates; // reprocessingRecipes: item → all reprocessing-recipe refs (probability // values are raw from config; we normalize them per-recipe below). std::map> reprocessingRecipes; for (const RecipeDef& recipe : config.recipes.recipes) { if (recipe.building == BuildingType::ReprocessingPlant) { // Compute the total weight across all outputs of this reprocessing recipe // so we can normalize each output's probability. double totalWeight = 0.0; for (const RecipeOutput& out : recipe.outputs) { totalWeight += out.probability.value_or(1.0); } if (totalWeight <= 0.0) { continue; } for (const RecipeOutput& out : recipe.outputs) { RecipeRef ref; ref.recipe = &recipe; ref.outputItem = out.item; ref.outputAmount = out.amount; ref.probability = out.probability.value_or(1.0) / totalWeight; reprocessingRecipes[out.item].push_back(ref); } } else { // Check whether this non-reprocessing recipe consumes scrap. bool consumesScrap = false; for (const RecipeIngredient& input : recipe.inputs) { if (input.item == "scrap") { consumesScrap = true; break; } } for (const RecipeOutput& out : recipe.outputs) { if (!consumesScrap) { scrapFreeItems.insert(out.item); } EligiblePair pair; pair.recipe = &recipe; pair.outputAmount = out.amount; pair.consumesScrap = consumesScrap; nonReprocessingCandidates[out.item].push_back(pair); } } } // Filter nonReprocessingCandidates: for items that have at least one // scrap-free producer, drop their scrap-consuming recipes. // Build the final per-item list of (recipe, outputAmount) pairs eligible // for the max-across-recipes rule (REQ-THREAT-ITEM). std::map>> eligibleRecipes; for (std::map>::const_iterator it = nonReprocessingCandidates.begin(); it != nonReprocessingCandidates.end(); ++it) { const std::string& item = it->first; const std::vector& candidates = it->second; bool hasScrapFree = (scrapFreeItems.find(item) != scrapFreeItems.end()); for (const EligiblePair& candidate : candidates) { if (candidate.consumesScrap && hasScrapFree) { // Scrap-consuming recipe excluded: item has a scrap-free producer. continue; } eligibleRecipes[item].emplace_back(candidate.recipe, candidate.outputAmount); } } // ------------------------------------------------------------------------- // Resolution: seed resolved map with scrap, then alternate the // non-reprocessing pass and the reprocessing pass to a fixpoint. // Fix (8): iterate until neither pass makes progress, rather than running // the reprocessing pass once at the end. // ------------------------------------------------------------------------- std::map& resolved = table.itemThreat; resolved["scrap"] = table.scrapThreat; // Non-reprocessing resolution pass. // Fix (9): commit an item only when EVERY eligible recipe for it is // computable, not just the first one that resolves. This ensures a shallow // shortcut recipe cannot undercut a deeper base recipe by resolving earlier. auto runNonReprocessingPass = [&](bool requireAllRecipes) -> bool { bool progress = false; std::map newValues; for (std::map>>::const_iterator it = eligibleRecipes.begin(); it != eligibleRecipes.end(); ++it) { const std::string& item = it->first; if (resolved.find(item) != resolved.end()) { continue; } const std::vector>& pairs = it->second; bool allComputable = true; double maxThreat = -1.0; for (const std::pair& pair : pairs) { if (!allInputsResolved(*pair.first, resolved)) { allComputable = false; if (requireAllRecipes) { break; } // In fallback mode: skip this recipe but continue gathering // the computable subset. continue; } double threat = computeRecipeThreatPerUnit(*pair.first, pair.second, resolved); if (threat > maxThreat) { maxThreat = threat; } } if (requireAllRecipes && !allComputable) { continue; } if (maxThreat >= 0.0) { newValues[item] = maxThreat; } } for (std::map::const_iterator it = newValues.begin(); it != newValues.end(); ++it) { resolved[it->first] = it->second; progress = true; } return progress; }; // Reprocessing pass: resolve items produced exclusively by reprocessing. // Items that also have a non-reprocessing recipe are skipped here (they are // covered by the non-reprocessing pass or do not need the reprocessing path). auto runReprocessingPass = [&]() -> bool { bool progress = false; for (std::map>::const_iterator it = reprocessingRecipes.begin(); it != reprocessingRecipes.end(); ++it) { const std::string& item = it->first; if (resolved.find(item) != resolved.end()) { continue; } // Reprocessing defines an item's threat only when nothing else // produces it (REQ-THREAT-ITEM). if (scrapFreeItems.find(item) != scrapFreeItems.end()) { continue; } // Also skip items covered by eligible (non-reprocessing) recipes. if (eligibleRecipes.find(item) != eligibleRecipes.end()) { continue; } for (const RecipeRef& ref : it->second) { // Sum all scrap inputs for this reprocessing recipe. int scrapPerCycle = 0; for (const RecipeIngredient& input : ref.recipe->inputs) { scrapPerCycle += input.amount; } double threat = (table.scrapThreat * scrapPerCycle + ref.recipe->durationSeconds) / ref.probability; std::map::iterator existing = resolved.find(item); if (existing == resolved.end() || threat > existing->second) { resolved[item] = threat; progress = true; } } } return progress; }; // Main fixpoint loop: alternate non-reprocessing and reprocessing passes // until neither makes any progress (fix 8). bool anyProgress = true; while (anyProgress) { anyProgress = runNonReprocessingPass(true); anyProgress = runReprocessingPass() || anyProgress; } // Deadlock fallback: if any items remain unresolved due to recipe cycles, // fall back to committing with the max over the currently computable subset // of recipes (fix 9, deadlock guard — same approach as threat_report.py's // require_all_recipes=False mode). anyProgress = true; while (anyProgress) { anyProgress = runNonReprocessingPass(false); anyProgress = runReprocessingPass() || anyProgress; } // Remove the sentinel scrap entry — scrapThreat is already stored on the // table struct; having it in itemThreat would confuse callers iterating items. resolved.erase("scrap"); return table; } double calculateShipThreatCost(const ThreatCostTable& table, const GameConfig& config, const std::string& shipId, const std::vector& modules) { const ShipDef* shipDef = config.ships.findShipDef(shipId); if (shipDef == nullptr) { return 0.0; } double threat = shipDef->schematic.productionTimeSeconds; // Add material threat for ship base materials. threat += computeMaterialThreat(table, shipDef->schematic.materials); // Add module production times and material threats. for (const PlacedModule& pm : modules) { const ModuleDef* moduleDef = config.modules.findModuleDef(pm.moduleId); if (moduleDef == nullptr) { continue; } threat += moduleDef->productionTimeSeconds; threat += computeMaterialThreat(table, moduleDef->materials); } return threat; }