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
This commit is contained in:
@@ -12,27 +12,27 @@
|
||||
namespace
|
||||
{
|
||||
|
||||
// Folds the output capacities one recipe implies into `caps`: twice each produced
|
||||
// item's per-cycle amount (REQ-MAT-OUTPUT-BUFFER). A Reprocessing Plant rolls exactly
|
||||
// one of its outputs per cycle (REQ-BLD-REPROCESSING), so its per-cycle amount for an
|
||||
// item is that one outcome's amount rather than a sum over the entries.
|
||||
// Folds the output capacities one recipe implies into `caps`: twice each produced item's
|
||||
// per-cycle amount (REQ-MAT-OUTPUT-BUFFER). A cycle yields exactly one output group
|
||||
// (REQ-MAT-OUTPUT-GROUP), so an item's per-cycle amount is the largest total any single
|
||||
// group produces of it -- summed within a group, whose items come together, and taken at
|
||||
// its maximum across groups, of which only one ever happens.
|
||||
//
|
||||
// Where a cap is already present the larger wins, which is how an auto-recipe building
|
||||
// unions the recipes of its type -- the same rule its input caps follow.
|
||||
void addOutputCaps(std::map<ItemType, int>& caps, BuildingType type,
|
||||
const RecipeDef& recipe)
|
||||
// Where a cap is already present the larger wins, which is how a cap unions across the
|
||||
// recipes it could be sized over -- the same rule the input caps follow.
|
||||
void addOutputCaps(std::map<ItemType, int>& caps, const RecipeDef& recipe)
|
||||
{
|
||||
std::map<ItemType, int> perCycle;
|
||||
for (const RecipeOutput& out : recipe.outputs)
|
||||
for (const RecipeOutputGroup& group : recipe.outputGroups)
|
||||
{
|
||||
const ItemType item{out.item};
|
||||
if (type == BuildingType::ReprocessingPlant)
|
||||
std::map<ItemType, int> inGroup;
|
||||
for (const RecipeOutput& out : group.items)
|
||||
{
|
||||
perCycle[item] = std::max(perCycle[item], out.amount);
|
||||
inGroup[ItemType{out.item}] += out.amount;
|
||||
}
|
||||
else
|
||||
for (const std::pair<const ItemType, int>& entry : inGroup)
|
||||
{
|
||||
perCycle[item] += out.amount;
|
||||
perCycle[entry.first] = std::max(perCycle[entry.first], entry.second);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -57,7 +57,7 @@ void initBuffers(Building& b, const RecipeDef& recipe)
|
||||
|
||||
b.outputBuffer.items.clear();
|
||||
b.outputBuffer.caps.clear();
|
||||
addOutputCaps(b.outputBuffer.caps, b.type, recipe);
|
||||
addOutputCaps(b.outputBuffer.caps, recipe);
|
||||
}
|
||||
|
||||
void initShipyardBuffers(const GameConfig& config, Building& b)
|
||||
|
||||
@@ -48,29 +48,62 @@ BuildingSystem::BuildingSystem(const GameConfig& config,
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
std::vector<Item> BuildingSystem::rollReprocessingOutput(const RecipeDef& recipe)
|
||||
namespace
|
||||
{
|
||||
std::vector<const RecipeOutput*> eligible;
|
||||
std::vector<double> weights;
|
||||
for (const RecipeOutput& out : recipe.outputs)
|
||||
// The items of one group, produced together (REQ-MAT-OUTPUT-GROUP).
|
||||
std::vector<Item> itemsOf(const RecipeOutputGroup& group)
|
||||
{
|
||||
std::vector<Item> result;
|
||||
for (const RecipeOutput& out : group.items)
|
||||
{
|
||||
if (!m_isItemUnlocked(out.item)) { continue; }
|
||||
eligible.push_back(&out);
|
||||
weights.push_back(out.probability.value_or(1.0));
|
||||
Item item;
|
||||
item.type.id = out.item;
|
||||
for (int i = 0; i < out.amount; ++i)
|
||||
{
|
||||
result.push_back(item);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
} // namespace
|
||||
|
||||
std::vector<Item> BuildingSystem::rollOutputGroup(const RecipeDef& recipe)
|
||||
{
|
||||
// One group: nothing to choose, so no weight is read, no draw is made, and no
|
||||
// eligibility is tested (REQ-MAT-OUTPUT-GROUP, REQ-LOCK-OUTPUT-POOL).
|
||||
//
|
||||
// Not drawing matters beyond speed. A draw here would consume entropy for every
|
||||
// ordinary recipe, shifting every later random outcome and invalidating recorded
|
||||
// replays. And eligibility must not apply either: implicit unlocking is derived from
|
||||
// demand, so an ordinary recipe's output can be perfectly producible while nothing
|
||||
// yet calls for it -- testing it here would stop the building producing at all.
|
||||
if (recipe.outputGroups.size() == 1)
|
||||
{
|
||||
return itemsOf(recipe.outputGroups.front());
|
||||
}
|
||||
|
||||
// Several groups: only those whose items are all unlocked can be picked, and a group
|
||||
// holding any locked item is dropped whole, since its items come together
|
||||
// (REQ-LOCK-OUTPUT-POOL). Weights are renormalized over what is left by
|
||||
// discrete_distribution.
|
||||
std::vector<const RecipeOutputGroup*> eligible;
|
||||
std::vector<double> weights;
|
||||
for (const RecipeOutputGroup& group : recipe.outputGroups)
|
||||
{
|
||||
bool allUnlocked = true;
|
||||
for (const RecipeOutput& out : group.items)
|
||||
{
|
||||
if (!m_isItemUnlocked(out.item)) { allUnlocked = false; break; }
|
||||
}
|
||||
if (!allUnlocked) { continue; }
|
||||
eligible.push_back(&group);
|
||||
weights.push_back(group.probability.value_or(1.0));
|
||||
}
|
||||
|
||||
if (eligible.empty()) { return {}; }
|
||||
|
||||
std::discrete_distribution<int> dist(weights.begin(), weights.end());
|
||||
const RecipeOutput& chosen = *eligible[static_cast<std::size_t>(dist(m_rng))];
|
||||
std::vector<Item> result;
|
||||
Item item;
|
||||
item.type.id = chosen.item;
|
||||
for (int i = 0; i < chosen.amount; ++i)
|
||||
{
|
||||
result.push_back(item);
|
||||
}
|
||||
return result;
|
||||
return itemsOf(*eligible[static_cast<std::size_t>(dist(m_rng))]);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -572,25 +605,11 @@ void BuildingSystem::tickProduction(FactoryState& state, Tick currentTick)
|
||||
continue;
|
||||
}
|
||||
|
||||
// 3. Determine chosen outputs (roll for reprocessing).
|
||||
std::vector<Item> chosen;
|
||||
if (building.type == BuildingType::ReprocessingPlant)
|
||||
{
|
||||
chosen = rollReprocessingOutput(*recipe);
|
||||
if (chosen.empty()) { continue; }
|
||||
}
|
||||
else
|
||||
{
|
||||
for (const RecipeOutput& out : recipe->outputs)
|
||||
{
|
||||
Item item;
|
||||
item.type.id = out.item;
|
||||
for (int i = 0; i < out.amount; ++i)
|
||||
{
|
||||
chosen.push_back(item);
|
||||
}
|
||||
}
|
||||
}
|
||||
// 3. Settle what this cycle produces: its one output group, picked by weight only
|
||||
// where the recipe has several (REQ-MAT-OUTPUT-GROUP). Empty means every group
|
||||
// was ineligible, so there is nothing to run.
|
||||
std::vector<Item> chosen = rollOutputGroup(*recipe);
|
||||
if (chosen.empty()) { continue; }
|
||||
|
||||
// 4. Consume inputs and start cycle.
|
||||
for (const RecipeIngredient& ing : recipe->inputs)
|
||||
|
||||
@@ -224,11 +224,11 @@ private:
|
||||
// (ignoring output-buffer space); drives the Starved/Blocked distinction of
|
||||
// the status light (REQ-UI-STATUS-LIGHT).
|
||||
|
||||
// Buffers for an auto-recipe building (Smelter, Reprocessing Plant): input
|
||||
// caps span the union of every recipe of the building's type; no player
|
||||
// recipe is selected (REQ-BLD-SMELTER, REQ-BLD-REPROCESSING).
|
||||
// Core input-edge scan shared by operational buildings and construction sites.
|
||||
std::vector<Item> rollReprocessingOutput(const RecipeDef& recipe);
|
||||
// What one cycle of this recipe produces: the items of its one output group
|
||||
// (REQ-MAT-OUTPUT-GROUP). Where the recipe has several, one is picked by weight from
|
||||
// those currently eligible (REQ-LOCK-OUTPUT-POOL) and the result is empty if none is;
|
||||
// where it has one, that group is returned with no draw and no eligibility test.
|
||||
std::vector<Item> rollOutputGroup(const RecipeDef& recipe);
|
||||
|
||||
const GameConfig& m_config;
|
||||
|
||||
|
||||
@@ -154,33 +154,22 @@ bool outputBufferHasRoom(const Building& b, const ItemType& type, int itemCount)
|
||||
|
||||
bool recipeOutputsFit(const Building& b, const RecipeDef& recipe)
|
||||
{
|
||||
if (b.type == BuildingType::ReprocessingPlant)
|
||||
for (const RecipeOutputGroup& group : recipe.outputGroups)
|
||||
{
|
||||
// One roll yields one of these, so each is measured on its own -- but all of them
|
||||
// have to fit, since which one it will be is not known yet.
|
||||
for (const RecipeOutput& out : recipe.outputs)
|
||||
// A group's items come together, so an item listed twice in one is produced in the
|
||||
// sum of those amounts and judged once, as a sum.
|
||||
std::map<ItemType, int> perCycle;
|
||||
for (const RecipeOutput& out : group.items)
|
||||
{
|
||||
if (!outputBufferHasRoom(b, ItemType{out.item}, out.amount))
|
||||
perCycle[ItemType{out.item}] += out.amount;
|
||||
}
|
||||
for (const std::pair<const ItemType, int>& entry : perCycle)
|
||||
{
|
||||
if (!outputBufferHasRoom(b, entry.first, entry.second))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// A deterministic cycle deposits all of its outputs together. An item listed more
|
||||
// than once is produced in the sum of those amounts, so it is judged once, as a sum.
|
||||
std::map<ItemType, int> perCycle;
|
||||
for (const RecipeOutput& out : recipe.outputs)
|
||||
{
|
||||
perCycle[ItemType{out.item}] += out.amount;
|
||||
}
|
||||
for (const std::pair<const ItemType, int>& entry : perCycle)
|
||||
{
|
||||
if (!outputBufferHasRoom(b, entry.first, entry.second))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -64,12 +64,11 @@ bool hasInputsToStart(const GameConfig& config, const Building& b);
|
||||
// counts against that material's capacity (REQ-MAT-OUTPUT-EMERGE, REQ-MAT-OUTPUT-BUFFER).
|
||||
bool outputBufferHasRoom(const Building& b, const ItemType& type, int itemCount);
|
||||
|
||||
// True when every output a cycle of this recipe could produce would fit -- the gate a
|
||||
// cycle has to pass before it may start (REQ-MAT-CYCLE). For a deterministic recipe that
|
||||
// is its own outputs. A Reprocessing Plant rolls one of its outputs per cycle
|
||||
// (REQ-BLD-REPROCESSING), so each possibility is judged on its own and all must fit: the
|
||||
// roll is committed the moment the cycle starts, and testing every outcome rather than
|
||||
// the rolled one is what keeps a stalled output belt from biasing the distribution.
|
||||
// True when every one of the recipe's output groups would fit -- the gate a cycle has to
|
||||
// pass before it may start (REQ-MAT-CYCLE, REQ-MAT-OUTPUT-GROUP). With a single group that
|
||||
// is simply that group. With several the pick is committed the moment the cycle starts, so
|
||||
// every outcome must fit: testing all of them rather than the picked one is what keeps a
|
||||
// stalled output belt from biasing the distribution.
|
||||
bool recipeOutputsFit(const Building& b, const RecipeDef& recipe);
|
||||
|
||||
// True when a production cycle could actually start right now: some candidate recipe
|
||||
|
||||
@@ -97,35 +97,42 @@ ThreatCostTable computeThreatCostTable(const GameConfig& config)
|
||||
// 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.building == BuildingType::ReprocessingPlant)
|
||||
if (recipe.outputGroups.size() > 1)
|
||||
{
|
||||
// Compute the total weight across all outputs of this reprocessing recipe
|
||||
// so we can normalize each output's probability.
|
||||
// Total weight across the groups, so each group's probability normalizes.
|
||||
double totalWeight = 0.0;
|
||||
for (const RecipeOutput& out : recipe.outputs)
|
||||
for (const RecipeOutputGroup& group : recipe.outputGroups)
|
||||
{
|
||||
totalWeight += out.probability.value_or(1.0);
|
||||
totalWeight += group.probability.value_or(1.0);
|
||||
}
|
||||
if (totalWeight <= 0.0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const RecipeOutput& out : recipe.outputs)
|
||||
for (const RecipeOutputGroup& group : recipe.outputGroups)
|
||||
{
|
||||
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);
|
||||
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 non-reprocessing recipe consumes scrap.
|
||||
// Check whether this single-group recipe consumes scrap.
|
||||
bool consumesScrap = false;
|
||||
for (const RecipeIngredient& input : recipe.inputs)
|
||||
{
|
||||
@@ -136,7 +143,7 @@ ThreatCostTable computeThreatCostTable(const GameConfig& config)
|
||||
}
|
||||
}
|
||||
|
||||
for (const RecipeOutput& out : recipe.outputs)
|
||||
for (const RecipeOutput& out : recipe.outputGroups.front().items)
|
||||
{
|
||||
if (!consumesScrap)
|
||||
{
|
||||
@@ -288,8 +295,13 @@ ThreatCostTable computeThreatCostTable(const GameConfig& config)
|
||||
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) / ref.probability;
|
||||
+ ref.recipe->durationSeconds) / perUnitDivisor;
|
||||
|
||||
std::map<std::string, double>::iterator existing = resolved.find(item);
|
||||
if (existing == resolved.end() || threat > existing->second)
|
||||
|
||||
@@ -244,9 +244,9 @@ UnlockState::UnlockedSets UnlockState::computeUnlockedSets(
|
||||
if (def.building == BuildingType::Assembler
|
||||
&& (def.unlockedAtStart || unlockedRecipeSchematicIds.count(def.id) > 0))
|
||||
{
|
||||
for (const RecipeOutput& out : def.outputs)
|
||||
for (const std::string& item : getProducibleItems(def))
|
||||
{
|
||||
result.itemIds.insert(out.item);
|
||||
result.itemIds.insert(item);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -272,9 +272,9 @@ UnlockState::UnlockedSets UnlockState::computeUnlockedSets(
|
||||
continue;
|
||||
}
|
||||
bool producesUnlocked = false;
|
||||
for (const RecipeOutput& out : recipe.outputs)
|
||||
for (const std::string& item : getProducibleItems(recipe))
|
||||
{
|
||||
if (result.itemIds.count(out.item) > 0)
|
||||
if (result.itemIds.count(item) > 0)
|
||||
{
|
||||
producesUnlocked = true;
|
||||
break;
|
||||
|
||||
Reference in New Issue
Block a user