allow the probabilistic recipe output of the reprocessing plant to yield more than 1 item of a type per cycle

This commit is contained in:
2026-08-12 21:45:09 +02:00
parent 0b859bd1a4
commit 4ee6438405
11 changed files with 358 additions and 206 deletions

View File

@@ -27,11 +27,14 @@ struct InputBuffer
std::map<ItemType, int> caps; // max items per material (2× per-cycle requirement)
};
// Output buffer shared by all output materials for a production building.
// Per-material output buffer for a production building. The items are held in one
// production-ordered queue -- that is the order they leave at the output port
// (REQ-MAT-OUTPUT-EMERGE) -- while the capacity is per item type, so one item's backlog
// never occupies another's room (REQ-MAT-OUTPUT-BUFFER).
struct OutputBuffer
{
std::vector<Item> items;
int capacity = 0; // 2× per-cycle output; 1× for ReprocessingPlant
std::vector<Item> items; // production order; feeds the output belt
std::map<ItemType, int> caps; // max items per material (2x its per-cycle amount)
};
// Active production cycle for a building.
@@ -96,6 +99,25 @@ struct Building
return count;
}
// The same over one material, which is what its own capacity is measured against
// (REQ-MAT-OUTPUT-BUFFER).
int getOutputItemCount(const ItemType& type) const
{
int count = 0;
for (const Item& item : outputBuffer.items)
{
if (item.type == type) { ++count; }
}
for (const std::vector<BeltItemSlot>& lane : emergingItems)
{
for (const BeltItemSlot& slot : lane)
{
if (slot.item.type == type) { ++count; }
}
}
return count;
}
// Items currently travelling inward on each input port's virtual input belt
// (REQ-MAT-INPUT-INTAKE); one lane per input port, parallel to inputPorts. Each
// lane holds slots at progress [0.0, 0.5], front (highest progress) first. An

View File

@@ -2,12 +2,48 @@
#include <algorithm>
#include <cassert>
#include <map>
#include "BuildingType.h"
#include "ItemType.h"
#include "ModulesConfig.h"
#include "ShipsConfig.h"
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.
//
// 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)
{
std::map<ItemType, int> perCycle;
for (const RecipeOutput& out : recipe.outputs)
{
const ItemType item{out.item};
if (type == BuildingType::ReprocessingPlant)
{
perCycle[item] = std::max(perCycle[item], out.amount);
}
else
{
perCycle[item] += out.amount;
}
}
for (const std::pair<const ItemType, int>& entry : perCycle)
{
caps[entry.first] = std::max(caps[entry.first], 2 * entry.second);
}
}
} // namespace
void initBuffers(Building& b, const RecipeDef& recipe)
{
b.inputBuffer.counts.clear();
@@ -20,29 +56,8 @@ void initBuffers(Building& b, const RecipeDef& recipe)
}
b.outputBuffer.items.clear();
if (b.type == BuildingType::ReprocessingPlant)
{
// 1× max-per-roll (REQ-MAT-OUTPUT-BUFFER-REPROCESSING).
int maxAmount = 0;
for (const RecipeOutput& out : recipe.outputs)
{
if (out.amount > maxAmount)
{
maxAmount = out.amount;
}
}
b.outputBuffer.capacity = maxAmount;
}
else
{
// 2× per-cycle output.
int totalAmount = 0;
for (const RecipeOutput& out : recipe.outputs)
{
totalAmount += out.amount;
}
b.outputBuffer.capacity = 2 * totalAmount;
}
b.outputBuffer.caps.clear();
addOutputCaps(b.outputBuffer.caps, b.type, recipe);
}
void initAutoBuffers(const GameConfig& config, Building& b)
@@ -50,12 +65,12 @@ void initAutoBuffers(const GameConfig& config, Building& b)
b.inputBuffer.counts.clear();
b.inputBuffer.caps.clear();
// Union the inputs of every recipe of this building type; the cap for each
// item is twice the largest per-cycle requirement across those recipes.
// Output capacity follows the same rules as initBuffers: the Reprocessing
// Plant holds one cycle's max output (REQ-MAT-OUTPUT-BUFFER-REPROCESSING),
// other auto buildings hold twice the largest per-cycle output.
int outputCapacity = 0;
b.outputBuffer.items.clear();
b.outputBuffer.caps.clear();
// Union both sides over every recipe of this building type: the cap for each item is
// twice the largest per-cycle amount across those recipes, on the input side as on
// the output side (REQ-MAT-INPUT-BUFFER, REQ-MAT-OUTPUT-BUFFER).
for (const RecipeDef& recipe : config.recipes.recipes)
{
if (recipe.building != b.type)
@@ -71,36 +86,18 @@ void initAutoBuffers(const GameConfig& config, Building& b)
std::max(b.inputBuffer.caps[type], 2 * ing.amount);
}
if (b.type == BuildingType::ReprocessingPlant)
{
int maxAmount = 0;
for (const RecipeOutput& out : recipe.outputs)
{
maxAmount = std::max(maxAmount, out.amount);
}
outputCapacity = std::max(outputCapacity, maxAmount);
}
else
{
int totalAmount = 0;
for (const RecipeOutput& out : recipe.outputs)
{
totalAmount += out.amount;
}
outputCapacity = std::max(outputCapacity, 2 * totalAmount);
}
addOutputCaps(b.outputBuffer.caps, b.type, recipe);
}
b.outputBuffer.items.clear();
b.outputBuffer.capacity = outputCapacity;
}
void initShipyardBuffers(const GameConfig& config, Building& b)
{
b.inputBuffer.counts.clear();
b.inputBuffer.caps.clear();
// A shipyard spawns a ship rather than producing items, so it holds no output
// buffer at all (REQ-MAT-OUTPUT-BUFFER, REQ-BLD-SHIPYARD).
b.outputBuffer.items.clear();
b.outputBuffer.capacity = 0;
b.outputBuffer.caps.clear();
const ShipDef* def = config.ships.findShipDef(b.recipeId);
if (!def)
{
@@ -133,11 +130,13 @@ void initShipyardBuffers(const GameConfig& config, Building& b)
void initSalvageBayBuffer(const GameConfig& config, Building& b)
{
// Salvage Bay has no recipe-driven buffer; its output-buffer holding size for
// ship drop-off is config-defined (REQ-BLD-SALVAGE-BAY).
// Salvage Bay has no recipe-driven buffer; scrap is the only thing it ever holds,
// and that single buffer's holding size for ship drop-off is config-defined
// (REQ-BLD-SALVAGE-BAY).
b.outputBuffer.items.clear();
b.outputBuffer.caps.clear();
const BuildingDef* def = config.buildings.findBuildingDef(BuildingType::SalvageBay);
b.outputBuffer.capacity =
b.outputBuffer.caps[ItemType{"scrap"}] =
(def && def->outputBufferCapacity) ? *def->outputBufferCapacity : 0;
}

View File

@@ -13,9 +13,9 @@
// to BeltSystem. Free functions over the config and the building — they read no
// factory state, so both BuildingSystem and ConstructionSystem can use them.
// Buffers for a building running one known recipe: inputs capped at twice each
// ingredient's per-cycle amount, output at twice the per-cycle total (one cycle's
// max for a Reprocessing Plant, REQ-MAT-OUTPUT-BUFFER-REPROCESSING).
// Buffers for a building running one known recipe: one buffer per material on each
// side, capped at twice that material's per-cycle amount (REQ-MAT-INPUT-BUFFER,
// REQ-MAT-OUTPUT-BUFFER).
void initBuffers(Building& b, const RecipeDef& recipe);
// Buffers for an auto-recipe building (Smelter, Reprocessing Plant), unioned over

View File

@@ -240,7 +240,7 @@ void BuildingSystem::setRecipe(FactoryState& state, BuildingId id, const std::st
building.inputBuffer.counts.clear();
building.inputBuffer.caps.clear();
building.outputBuffer.items.clear();
building.outputBuffer.capacity = 0;
building.outputBuffer.caps.clear();
// Emerging items are part of the output buffer, so clearing it on a
// recipe change discards them too (REQ-MAT-OUTPUT-EMERGE); in-transit
// input items are discarded and their reservations released
@@ -307,7 +307,7 @@ void BuildingSystem::setShipLayout(FactoryState& state, BuildingId id, const Shi
building.inputBuffer.counts.clear();
building.inputBuffer.caps.clear();
building.outputBuffer.items.clear();
building.outputBuffer.capacity = 0;
building.outputBuffer.caps.clear();
for (std::vector<BeltItemSlot>& lane : building.emergingItems) { lane.clear(); }
for (std::vector<BeltItemSlot>& lane : building.incomingItems) { lane.clear(); }
if (!building.recipeId.empty() && building.type == BuildingType::Shipyard)
@@ -547,7 +547,20 @@ void BuildingSystem::tickProduction(FactoryState& state, Tick currentTick)
continue;
}
// 2. Determine chosen outputs (roll for reprocessing).
// 2. Room for every output this cycle could produce -- checked before
// anything is rolled (REQ-MAT-CYCLE). The roll below is committed the
// moment the cycle starts, so a plant that could not store some outcome
// must not start at all: that is what stops a stalled output belt from
// biasing the distribution towards the outputs that still fit. Emerging
// items count against their buffer (REQ-MAT-OUTPUT-EMERGE). The status
// light asks the same question to decide yellow (REQ-UI-STATUS-LIGHT), so
// the test lives in one place.
if (!recipeOutputsFit(building, *recipe))
{
continue;
}
// 3. Determine chosen outputs (roll for reprocessing).
std::vector<Item> chosen;
if (building.type == BuildingType::ReprocessingPlant)
{
@@ -567,15 +580,6 @@ void BuildingSystem::tickProduction(FactoryState& state, Tick currentTick)
}
}
// 3. Output buffer has space for chosen outputs? Emerging items still
// count against the buffer (REQ-MAT-OUTPUT-EMERGE). The status light
// asks the same question to decide yellow (REQ-UI-STATUS-LIGHT), so the
// test lives in one place.
if (!outputBufferHasRoom(building, static_cast<int>(chosen.size())))
{
continue;
}
// 4. Consume inputs and start cycle.
for (const RecipeIngredient& ing : recipe->inputs)
{
@@ -950,21 +954,22 @@ void appendItems(Hasher& hasher, const std::vector<Item>& items)
}
}
// std::map<ItemType, int> iterates in sorted-id order (ItemType::operator<), so both
// buffer sides hash the same way in every run.
void appendItemCounts(Hasher& hasher, const std::map<ItemType, int>& counts)
{
hasher.append(counts.size());
for (const std::pair<const ItemType, int>& entry : counts)
{
hasher.append(entry.first.id);
hasher.append(entry.second);
}
}
void appendInputBuffer(Hasher& hasher, const InputBuffer& buffer)
{
// std::map<ItemType, int> iterates in sorted-id order (ItemType::operator<).
hasher.append(buffer.counts.size());
for (const std::pair<const ItemType, int>& entry : buffer.counts)
{
hasher.append(entry.first.id);
hasher.append(entry.second);
}
hasher.append(buffer.caps.size());
for (const std::pair<const ItemType, int>& entry : buffer.caps)
{
hasher.append(entry.first.id);
hasher.append(entry.second);
}
appendItemCounts(hasher, buffer.counts);
appendItemCounts(hasher, buffer.caps);
}
} // namespace
@@ -984,7 +989,7 @@ void BuildingSystem::appendChecksum(const FactoryState& state, Hasher& hasher) c
hasher.append(b.recipeId);
appendInputBuffer(hasher, b.inputBuffer);
appendItems(hasher, b.outputBuffer.items);
hasher.append(b.outputBuffer.capacity);
appendItemCounts(hasher, b.outputBuffer.caps);
hasher.append(b.emergingItems.size());
for (const std::vector<BeltItemSlot>& lane : b.emergingItems)
{

View File

@@ -4,6 +4,7 @@
#include <limits>
#include "PortGeometry.h"
#include "ProductionRules.h"
#include "SurfaceMask.h"
#include "Item.h"
@@ -122,12 +123,14 @@ bool deliverScrapToSalvageBay(FactoryState& state, BuildingId bayId)
return false; // queued for deconstruction: stopped operating (REQ-BLD-DECON-QUEUE)
}
// Emerging scrap still counts against the bay's holding capacity
// (REQ-MAT-OUTPUT-EMERGE).
if (bay->getOutputItemCount() >= bay->outputBuffer.capacity)
// (REQ-MAT-OUTPUT-EMERGE). Scrap is all the bay ever holds, so its single buffer is
// the one being filled (REQ-BLD-SALVAGE-BAY).
const ItemType scrap{"scrap"};
if (!outputBufferHasRoom(*bay, scrap, 1))
{
return false;
}
bay->outputBuffer.items.push_back(Item{ItemType{"scrap"}});
bay->outputBuffer.items.push_back(Item{scrap});
return true;
}

View File

@@ -1,47 +1,13 @@
#include "ProductionRules.h"
#include <algorithm>
#include <limits>
#include <map>
#include "BuildingType.h"
#include "ItemType.h"
#include "ModulesConfig.h"
#include "ShipsConfig.h"
namespace
{
// Items one cycle of this recipe would deposit into the output buffer. A Reprocessing
// Plant rolls exactly one of its outputs per cycle (REQ-BLD-REPROCESSING) and the roll
// happens in the simulation, so the smallest amount any roll could yield is what decides
// whether a cycle could start at all; a larger roll may still not fit.
int getCycleOutputItemCount(const Building& b, const RecipeDef& recipe)
{
if (recipe.outputs.empty())
{
return 0;
}
if (b.type == BuildingType::ReprocessingPlant)
{
int smallest = std::numeric_limits<int>::max();
for (const RecipeOutput& out : recipe.outputs)
{
smallest = std::min(smallest, out.amount);
}
return smallest;
}
int total = 0;
for (const RecipeOutput& out : recipe.outputs)
{
total += out.amount;
}
return total;
}
} // namespace
std::vector<const RecipeDef*>
gatherCandidateRecipes(const GameConfig& config, const Building& b)
{
@@ -177,9 +143,44 @@ bool hasInputsToStart(const GameConfig& config, const Building& b)
return false;
}
bool outputBufferHasRoom(const Building& b, int outputItemCount)
bool outputBufferHasRoom(const Building& b, const ItemType& type, int itemCount)
{
return b.getOutputItemCount() + outputItemCount <= b.outputBuffer.capacity;
const std::map<ItemType, int>::const_iterator capIt = b.outputBuffer.caps.find(type);
const int cap = (capIt != b.outputBuffer.caps.end()) ? capIt->second : 0;
return b.getOutputItemCount(type) + itemCount <= cap;
}
bool recipeOutputsFit(const Building& b, const RecipeDef& recipe)
{
if (b.type == BuildingType::ReprocessingPlant)
{
// 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)
{
if (!outputBufferHasRoom(b, ItemType{out.item}, out.amount))
{
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;
}
bool canStartCycle(const GameConfig& config, const Building& b)
@@ -193,8 +194,7 @@ bool canStartCycle(const GameConfig& config, const Building& b)
for (const RecipeDef* recipe : gatherCandidateRecipes(config, b))
{
if (recipeInputsAvailable(b, *recipe)
&& outputBufferHasRoom(b, getCycleOutputItemCount(b, *recipe)))
if (recipeInputsAvailable(b, *recipe) && recipeOutputsFit(b, *recipe))
{
return true;
}

View File

@@ -53,13 +53,21 @@ double computeShipyardProductionTimeSeconds(
// True when a production cycle could start right now, ignoring output-buffer space.
bool hasInputsToStart(const GameConfig& config, const Building& b);
// True when the building's output side can take `outputItemCount` more items beside
// what it already holds. An emerging item has not left the building yet and so still
// counts against the capacity (REQ-MAT-OUTPUT-EMERGE, REQ-MAT-OUTPUT-BUFFER).
bool outputBufferHasRoom(const Building& b, int outputItemCount);
// True when the building can take `itemCount` more items of `type` beside what it
// already holds of it. An emerging item has not left the building yet and so still
// 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.
bool recipeOutputsFit(const Building& b, const RecipeDef& recipe);
// True when a production cycle could actually start right now: some candidate recipe
// has its inputs *and* its output fits (REQ-MAT-CYCLE). Stricter than
// has its inputs *and* passes recipeOutputsFit (REQ-MAT-CYCLE). Stricter than
// hasInputsToStart, which looks at the input buffers alone.
bool canStartCycle(const GameConfig& config, const Building& b);