size the output buffer per item instead of per building

Implements the requirements from 8401b20. OutputBuffer's type-blind `capacity`
becomes a `caps` map keyed by item, mirroring InputBuffer. The items stay in
one production-ordered vector, because that order is what the output port hands
out (REQ-MAT-OUTPUT-EMERGE); only the capacity is now per type, so one item's
backlog no longer occupies another's room.

The cycle gate moves ahead of the roll in tickProduction: recipeOutputsFit()
requires every output a cycle could produce to fit, which for a deterministic
recipe is just its own outputs and for the Reprocessing Plant is every possible
roll. That is what denies the reroll the old one-item cap was there for -- a
full buffer for one item stops the plant entirely rather than letting it keep
producing the others -- while letting it hold two cycles' worth per outcome and
produce amounts above one.

getCycleOutputItemCount(), which judged the plant by the smallest amount any
roll could yield, is gone: it only existed because the classifier could not know
the roll, and the all-outcomes rule needs no such approximation. Its unlock
caveat goes with it, since a locked item is never produced and so never fills.

The Salvage Bay's config-defined capacity becomes its single scrap cap, and the
shipyard simply has no caps at all. The checksum hashes the map the way the
input caps are hashed, so determinism is unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ne3mejABZoLWKLh8fgpM3x
This commit is contained in:
2026-08-12 16:46:54 +02:00
parent 8401b20d35
commit 993b73a4c3
10 changed files with 346 additions and 193 deletions

View File

@@ -27,11 +27,14 @@ struct InputBuffer
std::map<ItemType, int> caps; // max items per material (2× per-cycle requirement) 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 struct OutputBuffer
{ {
std::vector<Item> items; std::vector<Item> items; // production order; feeds the output belt
int capacity = 0; // 2× per-cycle output; 1× for ReprocessingPlant std::map<ItemType, int> caps; // max items per material (2x its per-cycle amount)
}; };
// Active production cycle for a building. // Active production cycle for a building.
@@ -96,6 +99,25 @@ struct Building
return count; 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 // 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 // (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 // lane holds slots at progress [0.0, 0.5], front (highest progress) first. An

View File

@@ -2,12 +2,48 @@
#include <algorithm> #include <algorithm>
#include <cassert> #include <cassert>
#include <map>
#include "BuildingType.h" #include "BuildingType.h"
#include "ItemType.h" #include "ItemType.h"
#include "ModulesConfig.h" #include "ModulesConfig.h"
#include "ShipsConfig.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) void initBuffers(Building& b, const RecipeDef& recipe)
{ {
b.inputBuffer.counts.clear(); b.inputBuffer.counts.clear();
@@ -20,29 +56,8 @@ void initBuffers(Building& b, const RecipeDef& recipe)
} }
b.outputBuffer.items.clear(); b.outputBuffer.items.clear();
if (b.type == BuildingType::ReprocessingPlant) b.outputBuffer.caps.clear();
{ addOutputCaps(b.outputBuffer.caps, b.type, recipe);
// 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;
}
} }
void initAutoBuffers(const GameConfig& config, Building& b) void initAutoBuffers(const GameConfig& config, Building& b)
@@ -50,12 +65,12 @@ void initAutoBuffers(const GameConfig& config, Building& b)
b.inputBuffer.counts.clear(); b.inputBuffer.counts.clear();
b.inputBuffer.caps.clear(); b.inputBuffer.caps.clear();
// Union the inputs of every recipe of this building type; the cap for each b.outputBuffer.items.clear();
// item is twice the largest per-cycle requirement across those recipes. b.outputBuffer.caps.clear();
// Output capacity follows the same rules as initBuffers: the Reprocessing
// Plant holds one cycle's max output (REQ-MAT-OUTPUT-BUFFER-REPROCESSING), // Union both sides over every recipe of this building type: the cap for each item is
// other auto buildings hold twice the largest per-cycle output. // twice the largest per-cycle amount across those recipes, on the input side as on
int outputCapacity = 0; // the output side (REQ-MAT-INPUT-BUFFER, REQ-MAT-OUTPUT-BUFFER).
for (const RecipeDef& recipe : config.recipes.recipes) for (const RecipeDef& recipe : config.recipes.recipes)
{ {
if (recipe.building != b.type) 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); std::max(b.inputBuffer.caps[type], 2 * ing.amount);
} }
if (b.type == BuildingType::ReprocessingPlant) addOutputCaps(b.outputBuffer.caps, b.type, recipe);
{
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);
}
}
b.outputBuffer.items.clear();
b.outputBuffer.capacity = outputCapacity;
} }
void initShipyardBuffers(const GameConfig& config, Building& b) void initShipyardBuffers(const GameConfig& config, Building& b)
{ {
b.inputBuffer.counts.clear(); b.inputBuffer.counts.clear();
b.inputBuffer.caps.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.items.clear();
b.outputBuffer.capacity = 0; b.outputBuffer.caps.clear();
const ShipDef* def = config.ships.findShipDef(b.recipeId); const ShipDef* def = config.ships.findShipDef(b.recipeId);
if (!def) if (!def)
{ {
@@ -133,11 +130,13 @@ void initShipyardBuffers(const GameConfig& config, Building& b)
void initSalvageBayBuffer(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 // Salvage Bay has no recipe-driven buffer; scrap is the only thing it ever holds,
// ship drop-off is config-defined (REQ-BLD-SALVAGE-BAY). // and that single buffer's holding size for ship drop-off is config-defined
// (REQ-BLD-SALVAGE-BAY).
b.outputBuffer.items.clear(); b.outputBuffer.items.clear();
b.outputBuffer.caps.clear();
const BuildingDef* def = config.buildings.findBuildingDef(BuildingType::SalvageBay); const BuildingDef* def = config.buildings.findBuildingDef(BuildingType::SalvageBay);
b.outputBuffer.capacity = b.outputBuffer.caps[ItemType{"scrap"}] =
(def && def->outputBufferCapacity) ? *def->outputBufferCapacity : 0; (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 // to BeltSystem. Free functions over the config and the building — they read no
// factory state, so both BuildingSystem and ConstructionSystem can use them. // factory state, so both BuildingSystem and ConstructionSystem can use them.
// Buffers for a building running one known recipe: inputs capped at twice each // Buffers for a building running one known recipe: one buffer per material on each
// ingredient's per-cycle amount, output at twice the per-cycle total (one cycle's // side, capped at twice that material's per-cycle amount (REQ-MAT-INPUT-BUFFER,
// max for a Reprocessing Plant, REQ-MAT-OUTPUT-BUFFER-REPROCESSING). // REQ-MAT-OUTPUT-BUFFER).
void initBuffers(Building& b, const RecipeDef& recipe); void initBuffers(Building& b, const RecipeDef& recipe);
// Buffers for an auto-recipe building (Smelter, Reprocessing Plant), unioned over // 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.counts.clear();
building.inputBuffer.caps.clear(); building.inputBuffer.caps.clear();
building.outputBuffer.items.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 // Emerging items are part of the output buffer, so clearing it on a
// recipe change discards them too (REQ-MAT-OUTPUT-EMERGE); in-transit // recipe change discards them too (REQ-MAT-OUTPUT-EMERGE); in-transit
// input items are discarded and their reservations released // 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.counts.clear();
building.inputBuffer.caps.clear(); building.inputBuffer.caps.clear();
building.outputBuffer.items.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.emergingItems) { lane.clear(); }
for (std::vector<BeltItemSlot>& lane : building.incomingItems) { lane.clear(); } for (std::vector<BeltItemSlot>& lane : building.incomingItems) { lane.clear(); }
if (!building.recipeId.empty() && building.type == BuildingType::Shipyard) if (!building.recipeId.empty() && building.type == BuildingType::Shipyard)
@@ -547,7 +547,20 @@ void BuildingSystem::tickProduction(FactoryState& state, Tick currentTick)
continue; 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; std::vector<Item> chosen;
if (building.type == BuildingType::ReprocessingPlant) 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. // 4. Consume inputs and start cycle.
for (const RecipeIngredient& ing : recipe->inputs) 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) void appendInputBuffer(Hasher& hasher, const InputBuffer& buffer)
{ {
// std::map<ItemType, int> iterates in sorted-id order (ItemType::operator<). appendItemCounts(hasher, buffer.counts);
hasher.append(buffer.counts.size()); appendItemCounts(hasher, buffer.caps);
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);
}
} }
} // namespace } // namespace
@@ -984,7 +989,7 @@ void BuildingSystem::appendChecksum(const FactoryState& state, Hasher& hasher) c
hasher.append(b.recipeId); hasher.append(b.recipeId);
appendInputBuffer(hasher, b.inputBuffer); appendInputBuffer(hasher, b.inputBuffer);
appendItems(hasher, b.outputBuffer.items); appendItems(hasher, b.outputBuffer.items);
hasher.append(b.outputBuffer.capacity); appendItemCounts(hasher, b.outputBuffer.caps);
hasher.append(b.emergingItems.size()); hasher.append(b.emergingItems.size());
for (const std::vector<BeltItemSlot>& lane : b.emergingItems) for (const std::vector<BeltItemSlot>& lane : b.emergingItems)
{ {

View File

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

View File

@@ -1,47 +1,13 @@
#include "ProductionRules.h" #include "ProductionRules.h"
#include <algorithm> #include <algorithm>
#include <limits> #include <map>
#include "BuildingType.h" #include "BuildingType.h"
#include "ItemType.h" #include "ItemType.h"
#include "ModulesConfig.h" #include "ModulesConfig.h"
#include "ShipsConfig.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*> std::vector<const RecipeDef*>
gatherCandidateRecipes(const GameConfig& config, const Building& b) gatherCandidateRecipes(const GameConfig& config, const Building& b)
{ {
@@ -177,9 +143,44 @@ bool hasInputsToStart(const GameConfig& config, const Building& b)
return false; 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) 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)) for (const RecipeDef* recipe : gatherCandidateRecipes(config, b))
{ {
if (recipeInputsAvailable(b, *recipe) if (recipeInputsAvailable(b, *recipe) && recipeOutputsFit(b, *recipe))
&& outputBufferHasRoom(b, getCycleOutputItemCount(b, *recipe)))
{ {
return true; return true;
} }

View File

@@ -53,13 +53,21 @@ double computeShipyardProductionTimeSeconds(
// True when a production cycle could start right now, ignoring output-buffer space. // True when a production cycle could start right now, ignoring output-buffer space.
bool hasInputsToStart(const GameConfig& config, const Building& b); bool hasInputsToStart(const GameConfig& config, const Building& b);
// True when the building's output side can take `outputItemCount` more items beside // True when the building can take `itemCount` more items of `type` beside what it
// what it already holds. An emerging item has not left the building yet and so still // already holds of it. An emerging item has not left the building yet and so still
// counts against the capacity (REQ-MAT-OUTPUT-EMERGE, REQ-MAT-OUTPUT-BUFFER). // counts against that material's capacity (REQ-MAT-OUTPUT-EMERGE, REQ-MAT-OUTPUT-BUFFER).
bool outputBufferHasRoom(const Building& b, int outputItemCount); 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 // 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. // hasInputsToStart, which looks at the input buffers alone.
bool canStartCycle(const GameConfig& config, const Building& b); 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); const Building* bay = findBuilding(f.state, bayId);
REQUIRE(bay != nullptr); REQUIRE(bay != nullptr);
// Config-driven output-buffer capacity is applied on placement (REQ-BLD-SALVAGE-BAY). // Config-driven output-buffer capacity is applied on placement, onto the single
REQUIRE(bay->outputBuffer.capacity == 20); // 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, const QVector2D bayCenter(bay->anchor.x() + bay->footprint.width() / 2.0f,
bay->anchor.y() + bay->footprint.height() / 2.0f); bay->anchor.y() + bay->footprint.height() / 2.0f);

View File

@@ -14,6 +14,7 @@
#include "BeltSystem.h" #include "BeltSystem.h"
#include "Building.h" #include "Building.h"
#include "BuildingBuffers.h"
#include "BuildingSystem.h" #include "BuildingSystem.h"
#include "ConstructionSystem.h" #include "ConstructionSystem.h"
#include "DeconstructionSystem.h" #include "DeconstructionSystem.h"
@@ -876,7 +877,8 @@ TEST_CASE("BuildingSystem: direct coupling to a non-consumer leaves the item stu
REQUIRE(sink != nullptr); REQUIRE(sink != nullptr);
// Nothing was delivered, and the producer's output side has backed up to its cap. // Nothing was delivered, and the producer's output side has backed up to its cap.
REQUIRE(sink->pendingInputCount(ItemType{"iron_ore"}) == 0); 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]") "[building]")
{ {
PlacementFixture f; PlacementFixture f;
@@ -933,8 +935,124 @@ TEST_CASE("BuildingSystem: reprocessing plant output buffer capacity equals max
const Building* b = findBuilding(f.state, id); const Building* b = findBuilding(f.state, id);
REQUIRE(b != nullptr); REQUIRE(b != nullptr);
// reprocessing_cycle outputs: 2 iron_ingot (60%), 1 circuit_board (30%), // reprocessing_cycle outputs: 2 iron_ingot (60%), 1 circuit_board (30%),
// 1 advanced_alloy (10%). Max per roll = 2. Capacity = 2 (1× max). // 1 advanced_alloy (10%). One roll yields one of them, so each buffer holds twice
REQUIRE(b->outputBuffer.capacity == 2); // 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", TEST_CASE("BuildingSystem: reprocessing plant produces one cycle output then stalls",
@@ -1561,7 +1679,7 @@ TEST_CASE("BuildingSystem: getProductionStatus classifies production state", "[b
// A miner has no inputs, so its only idle reason is an output buffer with no // A miner has no inputs, so its only idle reason is an output buffer with no
// room for the next cycle's output. // room for the next cycle's output.
miner.production = std::nullopt; miner.production = std::nullopt;
miner.outputBuffer.capacity = 2; miner.outputBuffer.caps[ItemType{"iron_ore"}] = 2;
miner.outputBuffer.items = { makeItem("iron_ore"), makeItem("iron_ore") }; miner.outputBuffer.items = { makeItem("iron_ore"), makeItem("iron_ore") };
REQUIRE(statusOf(miner) == ProductionStatus::Blocked); // -> yellow REQUIRE(statusOf(miner) == ProductionStatus::Blocked); // -> yellow
@@ -1570,26 +1688,35 @@ TEST_CASE("BuildingSystem: getProductionStatus classifies production state", "[b
miner.outputBuffer.items.pop_back(); miner.outputBuffer.items.pop_back();
REQUIRE(statusOf(miner) == ProductionStatus::Producing); // -> green 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). // blocks the cycle again (REQ-MAT-OUTPUT-EMERGE).
miner.emergingItems.push_back({ BeltItemSlot{ makeItem("iron_ore"), 0.5 } }); 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 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") SECTION("Assembler: starved, the transient between cycles, then blocked")
{ {
Building assembler; assembler.type = BuildingType::Assembler; Building assembler; assembler.type = BuildingType::Assembler;
assembler.recipeId = assemblerRecipe->id; 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; int cycleOutput = 0;
for (const RecipeOutput& out : assemblerRecipe->outputs) for (const RecipeOutput& out : assemblerRecipe->outputs)
{ {
cycleOutput += out.amount; cycleOutput += out.amount;
} }
REQUIRE(cycleOutput > 0); REQUIRE(cycleOutput > 0);
// The buffer the simulation would give it (REQ-MAT-OUTPUT-BUFFER).
assembler.outputBuffer.capacity = 2 * cycleOutput;
// Idle with inputs missing -> red. // Idle with inputs missing -> red.
REQUIRE(statusOf(assembler) == ProductionStatus::Starved); REQUIRE(statusOf(assembler) == ProductionStatus::Starved);
@@ -1602,11 +1729,11 @@ TEST_CASE("BuildingSystem: getProductionStatus classifies production state", "[b
} }
REQUIRE(statusOf(assembler) == ProductionStatus::Producing); REQUIRE(statusOf(assembler) == ProductionStatus::Producing);
// Filled to within less than one cycle's output of capacity: no cycle can start // That item's own buffer filled to within less than one cycle's output of its
// -> yellow. // capacity: no cycle can start -> yellow.
for (int i = 0; i < cycleOutput + 1; ++i) 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); REQUIRE(statusOf(assembler) == ProductionStatus::Blocked);
@@ -1634,10 +1761,11 @@ TEST_CASE("BuildingSystem: getProductionStatus classifies production state", "[b
{ {
cycleOutput += out.amount; cycleOutput += out.amount;
} }
const std::string outputItemId = multiOutputRecipe->outputs.front().item;
Building assembler; assembler.type = BuildingType::Assembler; Building assembler; assembler.type = BuildingType::Assembler;
assembler.recipeId = multiOutputRecipe->id; assembler.recipeId = multiOutputRecipe->id;
assembler.outputBuffer.capacity = 2 * cycleOutput; initBuffers(assembler, *multiOutputRecipe);
for (const RecipeIngredient& ing : multiOutputRecipe->inputs) for (const RecipeIngredient& ing : multiOutputRecipe->inputs)
{ {
assembler.inputBuffer.counts[ItemType{ing.item}] = ing.amount; 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. // One item short of a full cycle's worth of free space.
for (int i = 0; i < cycleOutput + 1; ++i) 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); REQUIRE(statusOf(assembler) == ProductionStatus::Blocked);
// Exactly one cycle's worth of free space: the cycle fits again. // 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); 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 // 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 is committed at cycle start, so every outcome has to fit before it may
// roll could start: it is blocked once not even the smallest output fits. // begin: one full buffer blocks it whatever room the others have (REQ-MAT-CYCLE).
const RecipeDef* reprocessingRecipe = nullptr; const RecipeDef* reprocessingRecipe = nullptr;
for (const RecipeDef& r : f.cfg.recipes.recipes) for (const RecipeDef& r : f.cfg.recipes.recipes)
{ {
@@ -1671,50 +1800,31 @@ TEST_CASE("BuildingSystem: getProductionStatus classifies production state", "[b
} }
} }
REQUIRE(reprocessingRecipe != nullptr); REQUIRE(reprocessingRecipe != nullptr);
REQUIRE(reprocessingRecipe->outputs.size() >= 2);
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);
Building plant; plant.type = BuildingType::ReprocessingPlant; Building plant; plant.type = BuildingType::ReprocessingPlant;
// One cycle's largest output, as initAutoBuffers sizes it initBuffers(plant, *reprocessingRecipe);
// (REQ-MAT-OUTPUT-BUFFER-REPROCESSING).
plant.outputBuffer.capacity = largestOutput;
for (const RecipeIngredient& ing : reprocessingRecipe->inputs) for (const RecipeIngredient& ing : reprocessingRecipe->inputs)
{ {
plant.inputBuffer.counts[ItemType{ing.item}] = ing.amount; 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); REQUIRE(statusOf(plant) == ProductionStatus::Producing);
// Room for the smallest output but not for the largest: some roll can still // Fill one outcome's buffer and leave the rest untouched -> yellow, even though
// start, so the plant is waiting on the roll rather than blocked. // the other outcomes still have room.
if (smallestOutput < largestOutput) 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")); plant.outputBuffer.items.push_back(makeItem(firstItemId));
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"));
} }
REQUIRE(outputBufferHasRoom(plant, ItemType{lastItemId}, 1));
REQUIRE_FALSE(outputBufferHasRoom(plant, ItemType{firstItemId}, 1));
REQUIRE(statusOf(plant) == ProductionStatus::Blocked); 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(); plant.inputBuffer.counts.clear();
REQUIRE(statusOf(plant) == ProductionStatus::Starved); 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") SECTION("Salvage Bay: red when empty, green when holding scrap")
{ {
Building bay; bay.type = BuildingType::SalvageBay; Building bay; bay.type = BuildingType::SalvageBay;
bay.outputBuffer.capacity = 20; bay.outputBuffer.caps[ItemType{"scrap"}] = 20;
REQUIRE(statusOf(bay) == ProductionStatus::Starved); // empty -> red REQUIRE(statusOf(bay) == ProductionStatus::Starved); // empty -> red
bay.outputBuffer.items = { makeItem("scrap") }; bay.outputBuffer.items = { makeItem("scrap") };

View File

@@ -200,11 +200,16 @@ std::vector<ItemChipRow::Entry> BufferedBuildingContent::buildOutputEntries(
ItemChipRow::Entry chip; ItemChipRow::Entry chip;
chip.itemId = itemId; chip.itemId = itemId;
// Counted against the buffer's capacity, which is what production stops at // Counted against this item's own buffer capacity, which is what production
// (REQ-MAT-OUTPUT-BUFFER). // stops at (REQ-MAT-OUTPUT-BUFFER, REQ-UI-SINGLE-SELECTION). A chip for an item
chip.countText = building.outputBuffer.capacity > 0 // the building has no buffer for -- one left over from a previous recipe --
? tr("%1 / %2").arg(lookUp(buffered, itemId)) // carries the bare count, as an unsized buffer has no denominator to state.
.arg(building.outputBuffer.capacity) 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)); : QString::number(lookUp(buffered, itemId));
chip.subLine = QString::fromStdString(toDisplayName(itemId)); chip.subLine = QString::fromStdString(toDisplayName(itemId));
entries.push_back(chip); entries.push_back(chip);