From 993b73a4c3116a31700608247299ad15a78bc09d Mon Sep 17 00:00:00 2001 From: Malte Langkabel Date: Wed, 12 Aug 2026 16:46:54 +0200 Subject: [PATCH] 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 Claude-Session: https://claude.ai/code/session_01Ne3mejABZoLWKLh8fgpM3x --- src/lib/sim/Building.h | 28 ++- src/lib/sim/BuildingBuffers.cpp | 107 +++++---- src/lib/sim/BuildingBuffers.h | 6 +- src/lib/sim/BuildingSystem.cpp | 57 ++--- src/lib/sim/FactoryQueries.cpp | 9 +- src/lib/sim/ProductionRules.cpp | 78 +++---- src/lib/sim/ProductionRules.h | 18 +- src/test/BehaviorSystemTest.cpp | 5 +- src/test/BuildingTest.cpp | 216 ++++++++++++++----- src/ui/selection/BufferedBuildingContent.cpp | 15 +- 10 files changed, 346 insertions(+), 193 deletions(-) diff --git a/src/lib/sim/Building.h b/src/lib/sim/Building.h index a52f795..87833f8 100644 --- a/src/lib/sim/Building.h +++ b/src/lib/sim/Building.h @@ -27,11 +27,14 @@ struct InputBuffer std::map 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 items; - int capacity = 0; // 2× per-cycle output; 1× for ReprocessingPlant + std::vector items; // production order; feeds the output belt + std::map 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& 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 diff --git a/src/lib/sim/BuildingBuffers.cpp b/src/lib/sim/BuildingBuffers.cpp index 17736f1..25e94d4 100644 --- a/src/lib/sim/BuildingBuffers.cpp +++ b/src/lib/sim/BuildingBuffers.cpp @@ -2,12 +2,48 @@ #include #include +#include #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& caps, BuildingType type, + const RecipeDef& recipe) +{ + std::map 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& 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; } diff --git a/src/lib/sim/BuildingBuffers.h b/src/lib/sim/BuildingBuffers.h index 1a61eda..417f0aa 100644 --- a/src/lib/sim/BuildingBuffers.h +++ b/src/lib/sim/BuildingBuffers.h @@ -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 diff --git a/src/lib/sim/BuildingSystem.cpp b/src/lib/sim/BuildingSystem.cpp index 2f36f08..f63d412 100644 --- a/src/lib/sim/BuildingSystem.cpp +++ b/src/lib/sim/BuildingSystem.cpp @@ -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& lane : building.emergingItems) { lane.clear(); } for (std::vector& 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 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(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& items) } } +// std::map 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& counts) +{ + hasher.append(counts.size()); + for (const std::pair& entry : counts) + { + hasher.append(entry.first.id); + hasher.append(entry.second); + } +} + void appendInputBuffer(Hasher& hasher, const InputBuffer& buffer) { - // std::map iterates in sorted-id order (ItemType::operator<). - hasher.append(buffer.counts.size()); - for (const std::pair& entry : buffer.counts) - { - hasher.append(entry.first.id); - hasher.append(entry.second); - } - hasher.append(buffer.caps.size()); - for (const std::pair& 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& lane : b.emergingItems) { diff --git a/src/lib/sim/FactoryQueries.cpp b/src/lib/sim/FactoryQueries.cpp index 63834c9..911ef20 100644 --- a/src/lib/sim/FactoryQueries.cpp +++ b/src/lib/sim/FactoryQueries.cpp @@ -4,6 +4,7 @@ #include #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; } diff --git a/src/lib/sim/ProductionRules.cpp b/src/lib/sim/ProductionRules.cpp index 23ff4e2..7f26045 100644 --- a/src/lib/sim/ProductionRules.cpp +++ b/src/lib/sim/ProductionRules.cpp @@ -1,47 +1,13 @@ #include "ProductionRules.h" #include -#include +#include #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::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 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::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 perCycle; + for (const RecipeOutput& out : recipe.outputs) + { + perCycle[ItemType{out.item}] += out.amount; + } + for (const std::pair& 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; } diff --git a/src/lib/sim/ProductionRules.h b/src/lib/sim/ProductionRules.h index 7550328..a3e8b4a 100644 --- a/src/lib/sim/ProductionRules.h +++ b/src/lib/sim/ProductionRules.h @@ -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); diff --git a/src/test/BehaviorSystemTest.cpp b/src/test/BehaviorSystemTest.cpp index b2ee724..373f202 100644 --- a/src/test/BehaviorSystemTest.cpp +++ b/src/test/BehaviorSystemTest.cpp @@ -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); diff --git a/src/test/BuildingTest.cpp b/src/test/BuildingTest.cpp index 18b0b00..3e3580b 100644 --- a/src/test/BuildingTest.cpp +++ b/src/test/BuildingTest.cpp @@ -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(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(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(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") }; diff --git a/src/ui/selection/BufferedBuildingContent.cpp b/src/ui/selection/BufferedBuildingContent.cpp index f514e59..5fe8dcc 100644 --- a/src/ui/selection/BufferedBuildingContent.cpp +++ b/src/ui/selection/BufferedBuildingContent.cpp @@ -200,11 +200,16 @@ std::vector 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::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);