fix issue where status light was flickering to yellow for one tick between cycles
This commit is contained in:
@@ -563,10 +563,10 @@ void BuildingSystem::tickProduction(FactoryState& state, Tick currentTick)
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 3. Output buffer has space for chosen outputs? Emerging items still
|
// 3. Output buffer has space for chosen outputs? Emerging items still
|
||||||
// count against the buffer (REQ-MAT-OUTPUT-EMERGE).
|
// count against the buffer (REQ-MAT-OUTPUT-EMERGE). The status light
|
||||||
const int newSize = building.getOutputItemCount()
|
// asks the same question to decide yellow (REQ-UI-STATUS-LIGHT), so the
|
||||||
+ static_cast<int>(chosen.size());
|
// test lives in one place.
|
||||||
if (newSize > building.outputBuffer.capacity)
|
if (!outputBufferHasRoom(building, static_cast<int>(chosen.size())))
|
||||||
{
|
{
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,47 @@
|
|||||||
#include "ProductionRules.h"
|
#include "ProductionRules.h"
|
||||||
|
|
||||||
|
#include <algorithm>
|
||||||
|
#include <limits>
|
||||||
|
|
||||||
#include "BuildingType.h"
|
#include "BuildingType.h"
|
||||||
#include "ItemType.h"
|
#include "ItemType.h"
|
||||||
#include "ModulesConfig.h"
|
#include "ModulesConfig.h"
|
||||||
#include "ShipsConfig.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*>
|
std::vector<const RecipeDef*>
|
||||||
gatherCandidateRecipes(const GameConfig& config, const Building& b)
|
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.
|
// 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
|
// A Miner recipe has no inputs, so an idle Miner is always startable here and its
|
||||||
// only idle reason is a full output buffer.
|
// only idle reason is an output buffer without room for the next cycle.
|
||||||
for (const RecipeDef* recipe : gatherCandidateRecipes(config, b))
|
for (const RecipeDef* recipe : gatherCandidateRecipes(config, b))
|
||||||
{
|
{
|
||||||
if (recipeInputsAvailable(b, *recipe))
|
if (recipeInputsAvailable(b, *recipe))
|
||||||
@@ -139,6 +176,32 @@ bool hasInputsToStart(const GameConfig& config, const Building& b)
|
|||||||
}
|
}
|
||||||
return false;
|
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>
|
std::optional<ProductionStatus>
|
||||||
getProductionStatus(const GameConfig& config, const Building& building)
|
getProductionStatus(const GameConfig& config, const Building& building)
|
||||||
{
|
{
|
||||||
@@ -169,9 +232,18 @@ getProductionStatus(const GameConfig& config, const Building& building)
|
|||||||
return ProductionStatus::Producing;
|
return ProductionStatus::Producing;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Idle: missing inputs (red) take precedence over a full output buffer
|
// Idle, but blocked by neither condition: the building is only between cycles and
|
||||||
// (yellow). If inputs are present yet the building is idle, the only remaining
|
// the simulation starts the next one on a following tick. A building running back
|
||||||
// reason it could not start a cycle is a full output buffer (REQ-MAT-CYCLE).
|
// 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
|
return hasInputsToStart(config, building) ? ProductionStatus::Blocked
|
||||||
: ProductionStatus::Starved;
|
: ProductionStatus::Starved;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -53,6 +53,16 @@ double computeShipyardProductionTimeSeconds(
|
|||||||
// True when a production cycle could start right now, ignoring output-buffer space.
|
// True when a production cycle could start right now, ignoring output-buffer space.
|
||||||
bool hasInputsToStart(const GameConfig& config, const Building& b);
|
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,
|
// Status light for a building, or nullopt for types that show none — belts,
|
||||||
// splitters, tunnels, HQ and defence stations (REQ-UI-STATUS-LIGHT).
|
// splitters, tunnels, HQ and defence stations (REQ-UI-STATUS-LIGHT).
|
||||||
std::optional<ProductionStatus> getProductionStatus(const GameConfig& config,
|
std::optional<ProductionStatus> getProductionStatus(const GameConfig& config,
|
||||||
|
|||||||
@@ -1515,36 +1515,167 @@ TEST_CASE("BuildingSystem: getProductionStatus classifies production state", "[b
|
|||||||
miner.production = Production{};
|
miner.production = Production{};
|
||||||
REQUIRE(statusOf(miner) == ProductionStatus::Producing); // active cycle -> green
|
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.production = std::nullopt;
|
||||||
miner.outputBuffer.capacity = 2;
|
miner.outputBuffer.capacity = 2;
|
||||||
miner.outputBuffer.items = { makeItem("iron_ore"), makeItem("iron_ore") };
|
miner.outputBuffer.items = { makeItem("iron_ore"), makeItem("iron_ore") };
|
||||||
REQUIRE(statusOf(miner) == ProductionStatus::Blocked); // -> yellow
|
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;
|
Building assembler; assembler.type = BuildingType::Assembler;
|
||||||
assembler.recipeId = assemblerRecipe->id;
|
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.
|
// Idle with inputs missing -> red.
|
||||||
REQUIRE(statusOf(assembler) == ProductionStatus::Starved);
|
REQUIRE(statusOf(assembler) == ProductionStatus::Starved);
|
||||||
|
|
||||||
// Inputs present but idle -> the only remaining reason is a full output
|
// Inputs present and the output fits: nothing blocks a cycle, so the building
|
||||||
// buffer -> yellow.
|
// is merely between cycles -> green, not yellow (REQ-UI-STATUS-LIGHT).
|
||||||
for (const RecipeIngredient& ing : assemblerRecipe->inputs)
|
for (const RecipeIngredient& ing : assemblerRecipe->inputs)
|
||||||
{
|
{
|
||||||
assembler.inputBuffer.counts[ItemType{ing.item}] = ing.amount;
|
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);
|
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.inputBuffer.counts.clear();
|
||||||
assembler.outputBuffer.capacity = 2;
|
|
||||||
assembler.outputBuffer.items = { makeItem("x"), makeItem("x") };
|
|
||||||
REQUIRE(statusOf(assembler) == ProductionStatus::Starved);
|
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")
|
SECTION("Smelter (auto-recipe) is never grey")
|
||||||
{
|
{
|
||||||
Building smelter; smelter.type = BuildingType::Smelter;
|
Building smelter; smelter.type = BuildingType::Smelter;
|
||||||
|
|||||||
Reference in New Issue
Block a user