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