Fix ThreatCostCalculator: per-unit division, scrap fallback, fixpoint, staggered-recipe max
This commit is contained in:
@@ -30,6 +30,7 @@ double computeMaterialThreat(const ThreatCostTable& table,
|
||||
return total;
|
||||
}
|
||||
|
||||
// Returns true if every input of the recipe has a resolved threat value.
|
||||
bool allInputsResolved(const RecipeDef& recipe,
|
||||
const std::map<std::string, double>& resolved)
|
||||
{
|
||||
@@ -43,15 +44,18 @@ bool allInputsResolved(const RecipeDef& recipe,
|
||||
return true;
|
||||
}
|
||||
|
||||
double computeRecipeThreat(const RecipeDef& recipe,
|
||||
const std::map<std::string, double>& resolved)
|
||||
// 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<std::string, double>& resolved)
|
||||
{
|
||||
double threat = recipe.durationSeconds;
|
||||
for (const RecipeIngredient& input : recipe.inputs)
|
||||
{
|
||||
threat += resolved.at(input.item) * input.amount;
|
||||
}
|
||||
return threat;
|
||||
return threat / static_cast<double>(outputAmount);
|
||||
}
|
||||
|
||||
} // namespace
|
||||
@@ -69,118 +73,259 @@ ThreatCostTable computeThreatCostTable(const GameConfig& config)
|
||||
? 1.0 / config.world.scrapPerThreat
|
||||
: 0.0;
|
||||
|
||||
// Build lookup: output item → non-reprocessing recipes and reprocessing recipes.
|
||||
std::map<std::string, std::vector<RecipeRef>> nonReprocessingRecipes;
|
||||
// -------------------------------------------------------------------------
|
||||
// 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<std::string> 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<std::string, std::vector<EligiblePair>> nonReprocessingCandidates;
|
||||
|
||||
// reprocessingRecipes: item → all reprocessing-recipe refs (probability
|
||||
// values are raw from config; we normalize them per-recipe below).
|
||||
std::map<std::string, std::vector<RecipeRef>> 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);
|
||||
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)
|
||||
{
|
||||
RecipeRef ref;
|
||||
ref.recipe = &recipe;
|
||||
ref.outputItem = out.item;
|
||||
ref.outputAmount = out.amount;
|
||||
ref.probability = 1.0;
|
||||
nonReprocessingRecipes[out.item].push_back(ref);
|
||||
if (!consumesScrap)
|
||||
{
|
||||
scrapFreeItems.insert(out.item);
|
||||
}
|
||||
|
||||
EligiblePair pair;
|
||||
pair.recipe = &recipe;
|
||||
pair.outputAmount = out.amount;
|
||||
pair.consumesScrap = consumesScrap;
|
||||
nonReprocessingCandidates[out.item].push_back(pair);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Collect all item names that need resolving.
|
||||
std::set<std::string> unresolved;
|
||||
for (const std::pair<const std::string, std::vector<RecipeRef>>& entry : nonReprocessingRecipes)
|
||||
// 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<std::string, std::vector<std::pair<const RecipeDef*, int>>> eligibleRecipes;
|
||||
for (std::map<std::string, std::vector<EligiblePair>>::const_iterator it =
|
||||
nonReprocessingCandidates.begin();
|
||||
it != nonReprocessingCandidates.end();
|
||||
++it)
|
||||
{
|
||||
unresolved.insert(entry.first);
|
||||
}
|
||||
for (const std::pair<const std::string, std::vector<RecipeRef>>& entry : reprocessingRecipes)
|
||||
{
|
||||
unresolved.insert(entry.first);
|
||||
const std::string& item = it->first;
|
||||
const std::vector<EligiblePair>& 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);
|
||||
}
|
||||
}
|
||||
|
||||
// Iteratively resolve non-reprocessing items.
|
||||
bool progress = true;
|
||||
while (progress)
|
||||
// -------------------------------------------------------------------------
|
||||
// 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<std::string, double>& 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
|
||||
{
|
||||
progress = false;
|
||||
std::set<std::string> newlyResolved;
|
||||
for (const std::string& item : unresolved)
|
||||
bool progress = false;
|
||||
std::map<std::string, double> newValues;
|
||||
|
||||
for (std::map<std::string, std::vector<std::pair<const RecipeDef*, int>>>::const_iterator
|
||||
it = eligibleRecipes.begin();
|
||||
it != eligibleRecipes.end();
|
||||
++it)
|
||||
{
|
||||
std::map<std::string, std::vector<RecipeRef>>::const_iterator it =
|
||||
nonReprocessingRecipes.find(item);
|
||||
if (it == nonReprocessingRecipes.end())
|
||||
const std::string& item = it->first;
|
||||
if (resolved.find(item) != resolved.end())
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
const std::vector<std::pair<const RecipeDef*, int>>& pairs = it->second;
|
||||
|
||||
bool allComputable = true;
|
||||
double maxThreat = -1.0;
|
||||
for (const RecipeRef& ref : it->second)
|
||||
for (const std::pair<const RecipeDef*, int>& pair : pairs)
|
||||
{
|
||||
if (allInputsResolved(*ref.recipe, table.itemThreat))
|
||||
if (!allInputsResolved(*pair.first, resolved))
|
||||
{
|
||||
double threat = computeRecipeThreat(*ref.recipe, table.itemThreat);
|
||||
if (threat > maxThreat)
|
||||
allComputable = false;
|
||||
if (requireAllRecipes)
|
||||
{
|
||||
maxThreat = threat;
|
||||
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)
|
||||
{
|
||||
table.itemThreat[item] = maxThreat;
|
||||
newlyResolved.insert(item);
|
||||
progress = true;
|
||||
newValues[item] = maxThreat;
|
||||
}
|
||||
}
|
||||
for (const std::string& item : newlyResolved)
|
||||
{
|
||||
unresolved.erase(item);
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve reprocessing-only items.
|
||||
for (const std::string& item : unresolved)
|
||||
for (std::map<std::string, double>::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
|
||||
{
|
||||
std::map<std::string, std::vector<RecipeRef>>::const_iterator it =
|
||||
reprocessingRecipes.find(item);
|
||||
if (it == reprocessingRecipes.end())
|
||||
bool progress = false;
|
||||
for (std::map<std::string, std::vector<RecipeRef>>::const_iterator it =
|
||||
reprocessingRecipes.begin();
|
||||
it != reprocessingRecipes.end();
|
||||
++it)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const RecipeRef& ref : it->second)
|
||||
{
|
||||
int scrapPerCycle = 0;
|
||||
for (const RecipeIngredient& input : ref.recipe->inputs)
|
||||
const std::string& item = it->first;
|
||||
if (resolved.find(item) != resolved.end())
|
||||
{
|
||||
scrapPerCycle += input.amount;
|
||||
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;
|
||||
}
|
||||
|
||||
double threat = (table.scrapThreat * scrapPerCycle
|
||||
+ ref.recipe->durationSeconds) / ref.probability;
|
||||
std::map<std::string, double>::iterator existing = table.itemThreat.find(item);
|
||||
if (existing == table.itemThreat.end() || threat > existing->second)
|
||||
for (const RecipeRef& ref : it->second)
|
||||
{
|
||||
table.itemThreat[item] = threat;
|
||||
// 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<std::string, double>::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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user