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:
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user