fix issue where status light was flickering to yellow for one tick between cycles

This commit is contained in:
2026-08-12 21:26:09 +02:00
parent b6b6acb99c
commit dd061082fb
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;