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
376 lines
13 KiB
C++
376 lines
13 KiB
C++
#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;
|
||
}
|