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