Files
dota_factory/src/lib/sim/ThreatCostCalculator.cpp
Malte Langkabel 9c275e283c give every recipe one shape: a list of output groups
Implements REQ-MAT-OUTPUT-GROUP. A recipe had two shapes -- outputs produced
together, or outputs of which exactly one happened -- and every rule over them
was written twice, selected by `building == ReprocessingPlant`: sizing a
buffer, deciding whether a cycle fits, resolving what a cycle makes, costing an
item. RecipeDef now holds output groups, each a weight and a list of items, and
a cycle yields exactly one group. One group is the ordinary recipe, so the old
two cases are the same shape with one and with several, and all four rules
collapse to one expression apiece with no building-type test left.

rollReprocessingOutput becomes rollOutputGroup, where a single group returns
without drawing or testing eligibility. That early-out is load-bearing twice
over. Drawing there would consume entropy for every ordinary recipe and shift
every later random outcome; and eligibility must not apply either, since
implicit unlocking is demand-derived, so an ordinary recipe's output can be
producible while nothing yet calls for it -- testing it would stop the building
producing rather than gate a drop. Past the early-out a group is eligible only
when all of its items are unlocked, being produced whole.

Threat follows the recipe's shape rather than the building, and the per-unit
value now divides by the group's amount as well as its odds. That moves no
number today: every item resolved through this path has amount 1, which is why
the threat expectations are untouched.

Config keeps `outputs = [...]` as the single-group form, so only the two
reprocessing recipes change shape. The recipe summary gains "/" between groups
and keeps "+" within one, which also fixes the plant reading as though a cycle
produced all of its items at once.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ne3mejABZoLWKLh8fgpM3x
2026-08-17 12:47:09 +02:00

376 lines
13 KiB
C++
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#include "ThreatCostCalculator.h"
#include <set>
#include "GameConfig.h"
namespace
{
struct RecipeRef
{
const RecipeDef* recipe;
std::string outputItem;
int outputAmount;
double probability;
};
double computeMaterialThreat(const ThreatCostTable& table,
const std::vector<RecipeIngredient>& materials)
{
double total = 0.0;
for (const RecipeIngredient& mat : materials)
{
std::map<std::string, double>::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<std::string, double>& 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<std::string, double>& resolved)
{
double threat = recipe.durationSeconds;
for (const RecipeIngredient& input : recipe.inputs)
{
threat += resolved.at(input.item) * input.amount;
}
return threat / static_cast<double>(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<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;
// What decides which model an item's threat follows is the recipe's shape, not the
// building running it (REQ-MAT-OUTPUT-GROUP): a recipe with several groups yields one
// of them by chance, so its items cost the cycle divided by their odds; a recipe with
// one group yields it every cycle, so its items cost the cycle outright.
for (const RecipeDef& recipe : config.recipes.recipes)
{
if (recipe.outputGroups.size() > 1)
{
// Total weight across the groups, so each group's probability normalizes.
double totalWeight = 0.0;
for (const RecipeOutputGroup& group : recipe.outputGroups)
{
totalWeight += group.probability.value_or(1.0);
}
if (totalWeight <= 0.0)
{
continue;
}
for (const RecipeOutputGroup& group : recipe.outputGroups)
{
const double probability = group.probability.value_or(1.0) / totalWeight;
for (const RecipeOutput& out : group.items)
{
RecipeRef ref;
ref.recipe = &recipe;
ref.outputItem = out.item;
ref.outputAmount = out.amount;
ref.probability = probability;
reprocessingRecipes[out.item].push_back(ref);
}
}
}
else
{
// Check whether this single-group recipe consumes scrap.
bool consumesScrap = false;
for (const RecipeIngredient& input : recipe.inputs)
{
if (input.item == "scrap")
{
consumesScrap = true;
break;
}
}
for (const RecipeOutput& out : recipe.outputGroups.front().items)
{
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<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)
{
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);
}
}
// -------------------------------------------------------------------------
// 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
{
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)
{
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 std::pair<const RecipeDef*, int>& 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<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
{
bool progress = false;
for (std::map<std::string, std::vector<RecipeRef>>::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;
}
// Per unit: the cycle's cost, divided by the odds of getting this group
// at all and then by how many units that group yields (REQ-THREAT-ITEM).
const double perUnitDivisor =
ref.probability * static_cast<double>(ref.outputAmount);
if (perUnitDivisor <= 0.0) { continue; }
double threat = (table.scrapThreat * scrapPerCycle
+ ref.recipe->durationSeconds) / perUnitDivisor;
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;
}
double calculateShipThreatCost(const ThreatCostTable& table,
const GameConfig& config,
const std::string& shipId,
const std::vector<PlacedModule>& 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;
}