diff --git a/src/lib/sim/BuildingSystem.cpp b/src/lib/sim/BuildingSystem.cpp index 294fc59..3116a99 100644 --- a/src/lib/sim/BuildingSystem.cpp +++ b/src/lib/sim/BuildingSystem.cpp @@ -563,10 +563,10 @@ 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). - const int newSize = building.getOutputItemCount() - + static_cast(chosen.size()); - if (newSize > building.outputBuffer.capacity) + // 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; } diff --git a/src/lib/sim/ProductionRules.cpp b/src/lib/sim/ProductionRules.cpp index 7260c76..23ff4e2 100644 --- a/src/lib/sim/ProductionRules.cpp +++ b/src/lib/sim/ProductionRules.cpp @@ -1,10 +1,47 @@ #include "ProductionRules.h" +#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) { @@ -128,8 +165,8 @@ bool hasInputsToStart(const GameConfig& config, const Building& b) } // Recipe buildings: startable if any candidate recipe's inputs are satisfied. - // A Miner recipe has no inputs, so an idle Miner is always startable and its - // only idle reason is a full output buffer. + // A Miner recipe has no inputs, so an idle Miner is always startable here and its + // only idle reason is an output buffer without room for the next cycle. for (const RecipeDef* recipe : gatherCandidateRecipes(config, b)) { if (recipeInputsAvailable(b, *recipe)) @@ -139,6 +176,32 @@ bool hasInputsToStart(const GameConfig& config, const Building& b) } return false; } + +bool outputBufferHasRoom(const Building& b, int outputItemCount) +{ + return b.getOutputItemCount() + outputItemCount <= b.outputBuffer.capacity; +} + +bool canStartCycle(const GameConfig& config, const Building& b) +{ + // A shipyard's completed cycle spawns a ship instead of filling an output buffer + // (REQ-BLD-SHIPYARD), so holding the materials is the whole condition. + if (b.type == BuildingType::Shipyard) + { + return hasInputsToStart(config, b); + } + + for (const RecipeDef* recipe : gatherCandidateRecipes(config, b)) + { + if (recipeInputsAvailable(b, *recipe) + && outputBufferHasRoom(b, getCycleOutputItemCount(b, *recipe))) + { + return true; + } + } + return false; +} + std::optional getProductionStatus(const GameConfig& config, const Building& building) { @@ -169,9 +232,18 @@ getProductionStatus(const GameConfig& config, const Building& building) return ProductionStatus::Producing; } - // Idle: missing inputs (red) take precedence over a full output buffer - // (yellow). If inputs are present yet the building is idle, the only remaining - // reason it could not start a cycle is a full output buffer (REQ-MAT-CYCLE). + // Idle, but blocked by neither condition: the building is only between cycles and + // the simulation starts the next one on a following tick. A building running back + // to back sits here for exactly one tick per cycle, since tickProduction never + // starts a cycle in the tick one completed, so this must read as producing rather + // than blink (REQ-UI-STATUS-LIGHT, REQ-MAT-CYCLE). + if (canStartCycle(config, building)) + { + return ProductionStatus::Producing; + } + + // Idle for a reason: a missing input (red) takes precedence over an output buffer + // with no room for the next cycle's output (yellow). return hasInputsToStart(config, building) ? ProductionStatus::Blocked - : ProductionStatus::Starved; + : ProductionStatus::Starved; } diff --git a/src/lib/sim/ProductionRules.h b/src/lib/sim/ProductionRules.h index 8be66f2..7550328 100644 --- a/src/lib/sim/ProductionRules.h +++ b/src/lib/sim/ProductionRules.h @@ -53,6 +53,16 @@ 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 a production cycle could actually start right now: some candidate recipe +// has its inputs *and* its output fits (REQ-MAT-CYCLE). Stricter than +// hasInputsToStart, which looks at the input buffers alone. +bool canStartCycle(const GameConfig& config, const Building& b); + // Status light for a building, or nullopt for types that show none — belts, // splitters, tunnels, HQ and defence stations (REQ-UI-STATUS-LIGHT). std::optional getProductionStatus(const GameConfig& config, diff --git a/src/test/BuildingTest.cpp b/src/test/BuildingTest.cpp index 40c3a36..fd9cb4f 100644 --- a/src/test/BuildingTest.cpp +++ b/src/test/BuildingTest.cpp @@ -1515,36 +1515,167 @@ TEST_CASE("BuildingSystem: getProductionStatus classifies production state", "[b miner.production = Production{}; REQUIRE(statusOf(miner) == ProductionStatus::Producing); // active cycle -> green - // A miner has no inputs, so its only idle reason is a full output buffer. + // 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") }; REQUIRE(statusOf(miner) == ProductionStatus::Blocked); // -> yellow + + // One item handed off: the next cycle fits again, so the idle tick between two + // cycles reads as producing rather than blinking yellow (REQ-UI-STATUS-LIGHT). + 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 + // blocks the cycle again (REQ-MAT-OUTPUT-EMERGE). + miner.emergingItems.push_back({ BeltItemSlot{ makeItem("iron_ore"), 0.5 } }); + REQUIRE(miner.getOutputItemCount() == 2); + REQUIRE(statusOf(miner) == ProductionStatus::Blocked); // -> yellow } - SECTION("Assembler: starved vs blocked, input-missing takes precedence") + SECTION("Assembler: starved, the transient between cycles, then blocked") { Building assembler; assembler.type = BuildingType::Assembler; assembler.recipeId = assemblerRecipe->id; + 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); - // Inputs present but idle -> the only remaining reason is a full output - // buffer -> yellow. + // Inputs present and the output fits: nothing blocks a cycle, so the building + // is merely between cycles -> green, not yellow (REQ-UI-STATUS-LIGHT). for (const RecipeIngredient& ing : assemblerRecipe->inputs) { assembler.inputBuffer.counts[ItemType{ing.item}] = ing.amount; } + REQUIRE(statusOf(assembler) == ProductionStatus::Producing); + + // Filled to within less than one cycle's output of capacity: no cycle can start + // -> yellow. + for (int i = 0; i < cycleOutput + 1; ++i) + { + assembler.outputBuffer.items.push_back(makeItem("x")); + } REQUIRE(statusOf(assembler) == ProductionStatus::Blocked); - // Inputs missing AND output full -> red wins over yellow. + // Inputs missing AND output blocked -> red wins over yellow. assembler.inputBuffer.counts.clear(); - assembler.outputBuffer.capacity = 2; - assembler.outputBuffer.items = { makeItem("x"), makeItem("x") }; REQUIRE(statusOf(assembler) == ProductionStatus::Starved); } + SECTION("A multi-item cycle blocks before the output buffer is full") + { + // Free space smaller than one cycle's output stops the cycle even though the + // buffer still has room, so yellow is not the same as "full" (REQ-MAT-CYCLE). + const RecipeDef* multiOutputRecipe = nullptr; + for (const RecipeDef& r : f.cfg.recipes.recipes) + { + if (r.building != BuildingType::Assembler || r.inputs.empty()) { continue; } + int total = 0; + for (const RecipeOutput& out : r.outputs) { total += out.amount; } + if (total >= 2) { multiOutputRecipe = &r; break; } + } + REQUIRE(multiOutputRecipe != nullptr); + + int cycleOutput = 0; + for (const RecipeOutput& out : multiOutputRecipe->outputs) + { + cycleOutput += out.amount; + } + + Building assembler; assembler.type = BuildingType::Assembler; + assembler.recipeId = multiOutputRecipe->id; + assembler.outputBuffer.capacity = 2 * cycleOutput; + for (const RecipeIngredient& ing : multiOutputRecipe->inputs) + { + assembler.inputBuffer.counts[ItemType{ing.item}] = ing.amount; + } + + // 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")); + } + REQUIRE(assembler.getOutputItemCount() < assembler.outputBuffer.capacity); + REQUIRE(statusOf(assembler) == ProductionStatus::Blocked); + + // Exactly one cycle's worth of free space: the cycle fits again. + assembler.outputBuffer.items.pop_back(); + REQUIRE(statusOf(assembler) == ProductionStatus::Producing); + } + + SECTION("Reprocessing Plant: judged by the smallest output a roll could yield") + { + // 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. + const RecipeDef* reprocessingRecipe = nullptr; + for (const RecipeDef& r : f.cfg.recipes.recipes) + { + if (r.building == BuildingType::ReprocessingPlant && !r.inputs.empty()) + { + reprocessingRecipe = &r; + break; + } + } + 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); + + Building plant; plant.type = BuildingType::ReprocessingPlant; + // One cycle's largest output, as initAutoBuffers sizes it + // (REQ-MAT-OUTPUT-BUFFER-REPROCESSING). + plant.outputBuffer.capacity = largestOutput; + for (const RecipeIngredient& ing : reprocessingRecipe->inputs) + { + plant.inputBuffer.counts[ItemType{ing.item}] = ing.amount; + } + + // Empty buffer: a roll 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) + { + 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")); + } + REQUIRE(statusOf(plant) == ProductionStatus::Blocked); + + // Without the scrap it is starved regardless of the buffer. + plant.inputBuffer.counts.clear(); + REQUIRE(statusOf(plant) == ProductionStatus::Starved); + } + SECTION("Smelter (auto-recipe) is never grey") { Building smelter; smelter.type = BuildingType::Smelter;