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);

View File

@@ -1000,8 +1000,9 @@ TEST_CASE("SalvagerSystem: full-cargo ship at its SalvageBay hands over cargo",
}
const Building* bay = findBuilding(f.state, bayId);
REQUIRE(bay != nullptr);
// Config-driven output-buffer capacity is applied on placement (REQ-BLD-SALVAGE-BAY).
REQUIRE(bay->outputBuffer.capacity == 20);
// Config-driven output-buffer capacity is applied on placement, onto the single
// scrap buffer the bay holds (REQ-BLD-SALVAGE-BAY).
REQUIRE(bay->outputBuffer.caps.at(ItemType{"scrap"}) == 20);
const QVector2D bayCenter(bay->anchor.x() + bay->footprint.width() / 2.0f,
bay->anchor.y() + bay->footprint.height() / 2.0f);

View File

@@ -14,6 +14,7 @@
#include "BeltSystem.h"
#include "Building.h"
#include "BuildingBuffers.h"
#include "BuildingSystem.h"
#include "ConstructionSystem.h"
#include "DeconstructionSystem.h"
@@ -876,7 +877,8 @@ TEST_CASE("BuildingSystem: direct coupling to a non-consumer leaves the item stu
REQUIRE(sink != nullptr);
// Nothing was delivered, and the producer's output side has backed up to its cap.
REQUIRE(sink->pendingInputCount(ItemType{"iron_ore"}) == 0);
REQUIRE(miner->getOutputItemCount() == miner->outputBuffer.capacity);
REQUIRE(miner->getOutputItemCount(ItemType{"iron_ore"})
== miner->outputBuffer.caps.at(ItemType{"iron_ore"}));
}
// ---------------------------------------------------------------------------
@@ -913,10 +915,10 @@ TEST_CASE("BuildingSystem: setRecipe clears output buffer and active production"
}
// ---------------------------------------------------------------------------
// Reprocessing plant output buffer capacity (REQ-MAT-OUTPUT-BUFFER-REPROCESSING)
// Reprocessing plant -- per-item output buffers (REQ-MAT-OUTPUT-BUFFER)
// ---------------------------------------------------------------------------
TEST_CASE("BuildingSystem: reprocessing plant output buffer capacity equals max output per roll",
TEST_CASE("BuildingSystem: reprocessing plant sizes one output buffer per possible roll",
"[building]")
{
PlacementFixture f;
@@ -933,8 +935,124 @@ TEST_CASE("BuildingSystem: reprocessing plant output buffer capacity equals max
const Building* b = findBuilding(f.state, id);
REQUIRE(b != nullptr);
// reprocessing_cycle outputs: 2 iron_ingot (60%), 1 circuit_board (30%),
// 1 advanced_alloy (10%). Max per roll = 2. Capacity = 2 (1× max).
REQUIRE(b->outputBuffer.capacity == 2);
// 1 advanced_alloy (10%). One roll yields one of them, so each buffer holds twice
// that outcome's own amount (REQ-MAT-OUTPUT-BUFFER).
REQUIRE(b->outputBuffer.caps.size() == 3);
REQUIRE(b->outputBuffer.caps.at(ItemType{"iron_ingot"}) == 4);
REQUIRE(b->outputBuffer.caps.at(ItemType{"circuit_board"}) == 2);
REQUIRE(b->outputBuffer.caps.at(ItemType{"advanced_alloy"}) == 2);
}
TEST_CASE("BuildingSystem: one full output buffer stops the plant even when the others have room",
"[building]")
{
// The gate that replaced the old one-item cap: a cycle may only start when *every*
// outcome would fit, because the roll is committed once it starts (REQ-MAT-CYCLE).
// Were the plant to roll first and skip a result that does not fit, a player could
// stall one output belt to filter the distribution towards the other items.
PlacementFixture f(kFastBeltSpeed_tps);
const BuildingId id = f.bs.place(f.state, BuildingType::ReprocessingPlant,
QPoint(0, 0), Rotation::East, 0).value();
Tick tick = 0;
runTicks(f.bs, f.cfg, f.state, f.belts, f.stock,
static_cast<int>(secondsToTicks(25.0)) + 1, tick);
// Feed a full cycle's scrap (5) so only the output side can hold it back.
f.belts.placeBelt(QPoint(-1, 0), Rotation::East);
for (int i = 0; i < 5; ++i)
{
f.belts.tryPutItem(QPoint(-1, 0), makeItem("scrap"), Rotation::East);
f.belts.tick();
f.bs.tickBeltPull(f.state);
}
// Fill the iron_ingot buffer to its cap and leave the other two empty.
f.bs.forEachBuilding(f.state, [](Building& building) {
if (building.type != BuildingType::ReprocessingPlant) { return; }
const int cap = building.outputBuffer.caps.at(ItemType{"iron_ingot"});
for (int i = 0; i < cap; ++i)
{
building.outputBuffer.items.push_back(makeItem("iron_ingot"));
}
});
runTicks(f.bs, f.cfg, f.state, f.belts, f.stock, 5, tick);
const Building* b = findBuilding(f.state, id);
REQUIRE(b != nullptr);
// circuit_board and advanced_alloy have room, but iron_ingot does not, so no cycle
// starts at all and the scrap is still waiting.
REQUIRE(b->outputBuffer.caps.at(ItemType{"circuit_board"}) > 0);
REQUIRE(outputBufferHasRoom(*b, ItemType{"circuit_board"}, 1));
REQUIRE_FALSE(outputBufferHasRoom(*b, ItemType{"iron_ingot"}, 1));
REQUIRE_FALSE(b->production.has_value());
REQUIRE(b->pendingInputCount(ItemType{"scrap"}) == 5);
REQUIRE(getProductionStatus(f.cfg, *b) == ProductionStatus::Blocked);
}
TEST_CASE("BuildingSystem: reprocessing plant runs a second cycle while holding the first output",
"[building]")
{
// Its buffers hold twice each outcome's amount (REQ-MAT-OUTPUT-BUFFER), so a held
// result no longer stops the next cycle. The old one-item cap made this impossible:
// whatever the first roll was, the plant stalled until that item left the building.
PlacementFixture f(kFastBeltSpeed_tps);
const BuildingId id = f.bs.place(f.state, BuildingType::ReprocessingPlant,
QPoint(0, 0), Rotation::East, 0).value();
Tick tick = 0;
runTicks(f.bs, f.cfg, f.state, f.belts, f.stock,
static_cast<int>(secondsToTicks(25.0)) + 1, tick);
// Two cycles' worth of scrap (5 each), which is exactly the input cap.
f.belts.placeBelt(QPoint(-1, 0), Rotation::East);
for (int i = 0; i < 10; ++i)
{
f.belts.tryPutItem(QPoint(-1, 0), makeItem("scrap"), Rotation::East);
f.belts.tick();
f.bs.tickBeltPull(f.state);
}
REQUIRE(findBuilding(f.state, id)->pendingInputCount(ItemType{"scrap"}) == 10);
// No belt carries the output away, so the first cycle's result is still held.
// reprocessing_cycle runs 3s; run through the completion tick.
runTicks(f.bs, f.cfg, f.state, f.belts, f.stock,
static_cast<int>(secondsToTicks(3.0)) + 1, tick);
const Building* b = findBuilding(f.state, id);
REQUIRE(b != nullptr);
REQUIRE(b->getOutputItemCount() > 0);
// Whichever outcome was rolled, every outcome still fits, so the second cycle is
// already running rather than the plant sitting blocked.
REQUIRE(b->production.has_value());
REQUIRE(getProductionStatus(f.cfg, *b) == ProductionStatus::Producing);
}
TEST_CASE("BuildingSystem: one item's backlog does not block another item's cycle",
"[building]")
{
// Per-item buffers, so a smelter holding iron ingots can still smelt copper
// (REQ-MAT-OUTPUT-BUFFER). Under one shared capacity the iron would have blocked it.
PlacementFixture f;
Building smelter;
smelter.type = BuildingType::Smelter;
initAutoBuffers(f.cfg, smelter);
// Copper ore in, and the iron_ingot buffer filled to its cap.
smelter.inputBuffer.counts[ItemType{"copper_ore"}] =
smelter.inputBuffer.caps.at(ItemType{"copper_ore"});
const int ironCap = smelter.outputBuffer.caps.at(ItemType{"iron_ingot"});
for (int i = 0; i < ironCap; ++i)
{
smelter.outputBuffer.items.push_back(makeItem("iron_ingot"));
}
REQUIRE_FALSE(outputBufferHasRoom(smelter, ItemType{"iron_ingot"}, 1));
REQUIRE(outputBufferHasRoom(smelter, ItemType{"copper_ingot"}, 1));
REQUIRE(canStartCycle(f.cfg, smelter));
REQUIRE(getProductionStatus(f.cfg, smelter) == ProductionStatus::Producing);
}
TEST_CASE("BuildingSystem: reprocessing plant produces one cycle output then stalls",
@@ -1561,8 +1679,8 @@ TEST_CASE("BuildingSystem: getProductionStatus classifies production state", "[b
// A miner has no inputs, so its only idle reason is an output buffer with no
// room for the next cycle's output.
miner.production = std::nullopt;
miner.outputBuffer.capacity = 2;
miner.outputBuffer.items = { makeItem("iron_ore"), makeItem("iron_ore") };
miner.outputBuffer.caps[ItemType{"iron_ore"}] = 2;
miner.outputBuffer.items = { makeItem("iron_ore"), makeItem("iron_ore") };
REQUIRE(statusOf(miner) == ProductionStatus::Blocked); // -> yellow
// One item handed off: the next cycle fits again, so the idle tick between two
@@ -1570,26 +1688,35 @@ TEST_CASE("BuildingSystem: getProductionStatus classifies production state", "[b
miner.outputBuffer.items.pop_back();
REQUIRE(statusOf(miner) == ProductionStatus::Producing); // -> green
// An emerging item has not left the building, so it fills the freed slot and
// An emerging item has not left the building, so it fills the freed room and
// blocks the cycle again (REQ-MAT-OUTPUT-EMERGE).
miner.emergingItems.push_back({ BeltItemSlot{ makeItem("iron_ore"), 0.5 } });
REQUIRE(miner.getOutputItemCount() == 2);
REQUIRE(miner.getOutputItemCount(ItemType{"iron_ore"}) == 2);
REQUIRE(statusOf(miner) == ProductionStatus::Blocked); // -> yellow
// Another item's backlog is measured against its own buffer, so it changes
// nothing here (REQ-MAT-OUTPUT-BUFFER).
miner.outputBuffer.caps[ItemType{"copper_ore"}] = 2;
miner.outputBuffer.items.push_back(makeItem("copper_ore"));
REQUIRE(statusOf(miner) == ProductionStatus::Blocked);
miner.outputBuffer.items.clear();
REQUIRE(statusOf(miner) == ProductionStatus::Producing);
}
SECTION("Assembler: starved, the transient between cycles, then blocked")
{
Building assembler; assembler.type = BuildingType::Assembler;
assembler.recipeId = assemblerRecipe->id;
// Sized the way the simulation sizes it (REQ-MAT-OUTPUT-BUFFER).
initBuffers(assembler, *assemblerRecipe);
const std::string outputItemId = assemblerRecipe->outputs.front().item;
int cycleOutput = 0;
for (const RecipeOutput& out : assemblerRecipe->outputs)
{
cycleOutput += out.amount;
}
REQUIRE(cycleOutput > 0);
// The buffer the simulation would give it (REQ-MAT-OUTPUT-BUFFER).
assembler.outputBuffer.capacity = 2 * cycleOutput;
// Idle with inputs missing -> red.
REQUIRE(statusOf(assembler) == ProductionStatus::Starved);
@@ -1602,11 +1729,11 @@ TEST_CASE("BuildingSystem: getProductionStatus classifies production state", "[b
}
REQUIRE(statusOf(assembler) == ProductionStatus::Producing);
// Filled to within less than one cycle's output of capacity: no cycle can start
// -> yellow.
// That item's own buffer filled to within less than one cycle's output of its
// capacity: no cycle can start -> yellow.
for (int i = 0; i < cycleOutput + 1; ++i)
{
assembler.outputBuffer.items.push_back(makeItem("x"));
assembler.outputBuffer.items.push_back(makeItem(outputItemId));
}
REQUIRE(statusOf(assembler) == ProductionStatus::Blocked);
@@ -1634,10 +1761,11 @@ TEST_CASE("BuildingSystem: getProductionStatus classifies production state", "[b
{
cycleOutput += out.amount;
}
const std::string outputItemId = multiOutputRecipe->outputs.front().item;
Building assembler; assembler.type = BuildingType::Assembler;
assembler.recipeId = multiOutputRecipe->id;
assembler.outputBuffer.capacity = 2 * cycleOutput;
assembler.recipeId = multiOutputRecipe->id;
initBuffers(assembler, *multiOutputRecipe);
for (const RecipeIngredient& ing : multiOutputRecipe->inputs)
{
assembler.inputBuffer.counts[ItemType{ing.item}] = ing.amount;
@@ -1646,9 +1774,10 @@ TEST_CASE("BuildingSystem: getProductionStatus classifies production state", "[b
// One item short of a full cycle's worth of free space.
for (int i = 0; i < cycleOutput + 1; ++i)
{
assembler.outputBuffer.items.push_back(makeItem("x"));
assembler.outputBuffer.items.push_back(makeItem(outputItemId));
}
REQUIRE(assembler.getOutputItemCount() < assembler.outputBuffer.capacity);
REQUIRE(assembler.getOutputItemCount(ItemType{outputItemId})
< assembler.outputBuffer.caps.at(ItemType{outputItemId}));
REQUIRE(statusOf(assembler) == ProductionStatus::Blocked);
// Exactly one cycle's worth of free space: the cycle fits again.
@@ -1656,11 +1785,11 @@ TEST_CASE("BuildingSystem: getProductionStatus classifies production state", "[b
REQUIRE(statusOf(assembler) == ProductionStatus::Producing);
}
SECTION("Reprocessing Plant: judged by the smallest output a roll could yield")
SECTION("Reprocessing Plant: blocked once any possible roll has no room")
{
// The plant rolls one of its outputs per cycle (REQ-BLD-REPROCESSING) and the
// roll belongs to the simulation, so the status can only say whether *some*
// roll could start: it is blocked once not even the smallest output fits.
// roll is committed at cycle start, so every outcome has to fit before it may
// begin: one full buffer blocks it whatever room the others have (REQ-MAT-CYCLE).
const RecipeDef* reprocessingRecipe = nullptr;
for (const RecipeDef& r : f.cfg.recipes.recipes)
{
@@ -1671,50 +1800,31 @@ TEST_CASE("BuildingSystem: getProductionStatus classifies production state", "[b
}
}
REQUIRE(reprocessingRecipe != nullptr);
int smallestOutput = 0;
int largestOutput = 0;
for (const RecipeOutput& out : reprocessingRecipe->outputs)
{
if (smallestOutput == 0 || out.amount < smallestOutput)
{
smallestOutput = out.amount;
}
if (out.amount > largestOutput) { largestOutput = out.amount; }
}
REQUIRE(smallestOutput > 0);
REQUIRE(reprocessingRecipe->outputs.size() >= 2);
Building plant; plant.type = BuildingType::ReprocessingPlant;
// One cycle's largest output, as initAutoBuffers sizes it
// (REQ-MAT-OUTPUT-BUFFER-REPROCESSING).
plant.outputBuffer.capacity = largestOutput;
initBuffers(plant, *reprocessingRecipe);
for (const RecipeIngredient& ing : reprocessingRecipe->inputs)
{
plant.inputBuffer.counts[ItemType{ing.item}] = ing.amount;
}
// Empty buffer: a roll fits -> green.
// Every buffer empty: whatever the roll turns out to be, it fits -> green.
REQUIRE(statusOf(plant) == ProductionStatus::Producing);
// Room for the smallest output but not for the largest: some roll can still
// start, so the plant is waiting on the roll rather than blocked.
if (smallestOutput < largestOutput)
// Fill one outcome's buffer and leave the rest untouched -> yellow, even though
// the other outcomes still have room.
const std::string firstItemId = reprocessingRecipe->outputs.front().item;
const std::string lastItemId = reprocessingRecipe->outputs.back().item;
for (int i = 0; i < plant.outputBuffer.caps.at(ItemType{firstItemId}); ++i)
{
plant.outputBuffer.items.push_back(makeItem("iron_ingot"));
REQUIRE(plant.getOutputItemCount() + largestOutput
> plant.outputBuffer.capacity);
REQUIRE(statusOf(plant) == ProductionStatus::Producing);
plant.outputBuffer.items.pop_back();
}
// Filled so that not even the smallest output fits -> yellow.
for (int i = 0; i < largestOutput - smallestOutput + 1; ++i)
{
plant.outputBuffer.items.push_back(makeItem("iron_ingot"));
plant.outputBuffer.items.push_back(makeItem(firstItemId));
}
REQUIRE(outputBufferHasRoom(plant, ItemType{lastItemId}, 1));
REQUIRE_FALSE(outputBufferHasRoom(plant, ItemType{firstItemId}, 1));
REQUIRE(statusOf(plant) == ProductionStatus::Blocked);
// Without the scrap it is starved regardless of the buffer.
// Without the scrap it is starved regardless of the buffers.
plant.inputBuffer.counts.clear();
REQUIRE(statusOf(plant) == ProductionStatus::Starved);
}
@@ -1741,7 +1851,7 @@ TEST_CASE("BuildingSystem: getProductionStatus classifies production state", "[b
SECTION("Salvage Bay: red when empty, green when holding scrap")
{
Building bay; bay.type = BuildingType::SalvageBay;
bay.outputBuffer.capacity = 20;
bay.outputBuffer.caps[ItemType{"scrap"}] = 20;
REQUIRE(statusOf(bay) == ProductionStatus::Starved); // empty -> red
bay.outputBuffer.items = { makeItem("scrap") };

View File

@@ -75,7 +75,7 @@ BufferedBuildingContent::BufferedBuildingContent(const SelectionContext& context
m_production = new ProductionSection(this);
m_outputSection = new SectionBox(tr("Output buffer"), this);
m_outputSection = new SectionBox(tr("Output buffers"), this);
m_outputChips = new ItemChipRow(context, m_outputSection);
m_outputSection->getContentLayout()->addWidget(m_outputChips);
@@ -200,11 +200,16 @@ std::vector<ItemChipRow::Entry> BufferedBuildingContent::buildOutputEntries(
ItemChipRow::Entry chip;
chip.itemId = itemId;
// Counted against the buffer's capacity, which is what production stops at
// (REQ-MAT-OUTPUT-BUFFER).
chip.countText = building.outputBuffer.capacity > 0
? tr("%1 / %2").arg(lookUp(buffered, itemId))
.arg(building.outputBuffer.capacity)
// Counted against this item's own buffer capacity, which is what production
// stops at (REQ-MAT-OUTPUT-BUFFER, REQ-UI-SINGLE-SELECTION). A chip for an item
// the building has no buffer for -- one left over from a previous recipe --
// carries the bare count, as an unsized buffer has no denominator to state.
const std::map<ItemType, int>::const_iterator capIt =
building.outputBuffer.caps.find(ItemType{itemId});
const int cap =
(capIt != building.outputBuffer.caps.end()) ? capIt->second : 0;
chip.countText = cap > 0
? tr("%1 / %2").arg(lookUp(buffered, itemId)).arg(cap)
: QString::number(lookUp(buffered, itemId));
chip.subLine = QString::fromStdString(toDisplayName(itemId));
entries.push_back(chip);