show green while a building is only between cycles

tickProduction never starts a cycle in the tick one completed, so a building
running back to back is idle for exactly one tick per cycle. The status light
classified that tick as "output full" -- hasInputsToStart looks at the input
buffers alone -- so the pill blinked green/yellow once per cycle, roughly once
a second on a one-second recipe.

REQ-UI-STATUS-LIGHT already says a building that is idle yet blocked by
neither condition shows green. Add canStartCycle(), which requires a candidate
recipe's inputs *and* room for its output, and classify on it; yellow now
needs an output buffer that genuinely cannot take the next cycle. The
Reprocessing Plant rolls its output in the simulation, so the classifier
judges it by the smallest amount any roll could yield.

tickProduction's space check goes through the same outputBufferHasRoom(), so
the simulation and the light cannot drift apart.

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 12:38:21 +02:00
parent ab4aef2ad5
commit 74233f5bc5
4 changed files with 230 additions and 17 deletions

View File

@@ -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<int>(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<int>(chosen.size())))
{
continue;
}

View File

@@ -1,10 +1,47 @@
#include "ProductionRules.h"
#include <algorithm>
#include <limits>
#include "BuildingType.h"
#include "ItemType.h"
#include "ModulesConfig.h"
#include "ShipsConfig.h"
namespace
{
// Items one cycle of this recipe would deposit into the output buffer. A Reprocessing
// Plant rolls exactly one of its outputs per cycle (REQ-BLD-REPROCESSING) and the roll
// happens in the simulation, so the smallest amount any roll could yield is what decides
// whether a cycle could start at all; a larger roll may still not fit.
int getCycleOutputItemCount(const Building& b, const RecipeDef& recipe)
{
if (recipe.outputs.empty())
{
return 0;
}
if (b.type == BuildingType::ReprocessingPlant)
{
int smallest = std::numeric_limits<int>::max();
for (const RecipeOutput& out : recipe.outputs)
{
smallest = std::min(smallest, out.amount);
}
return smallest;
}
int total = 0;
for (const RecipeOutput& out : recipe.outputs)
{
total += out.amount;
}
return total;
}
} // namespace
std::vector<const RecipeDef*>
gatherCandidateRecipes(const GameConfig& config, const Building& b)
{
@@ -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<ProductionStatus>
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;
}

View File

@@ -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<ProductionStatus> getProductionStatus(const GameConfig& config,

View File

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