Auto-process smelter and reprocessing plant (no recipe selection)

Bring the code in line with REQ-BLD-SMELTER, REQ-BLD-REPROCESSING, and
REQ-UI-SELECT-BUTTON: the Smelter and Reprocessing Plant no longer require a
player-selected recipe.

Simulation: auto-recipe buildings init input caps from the union of their
type's recipe inputs, accept any matching item, and each idle tick run the
first recipe whose inputs are satisfied (reprocessing keeps its weighted roll).
setRecipe is a no-op for these types.

UI: the recipe-select button is hidden for auto-recipe buildings; the selected
building panel still shows buffers and production progress using the recipe
currently in production.

Tests: drop the obsolete setRecipe calls; add smelter auto-smelt coverage,
including the mixed-input case where a satisfiable recipe runs while an
incomplete batch of another input waits.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DZR44tA8sn4dPqDzAVXyps
This commit is contained in:
2026-07-12 21:30:30 +02:00
parent 3d4c0445d1
commit 2a4bd0b066
4 changed files with 354 additions and 92 deletions

View File

@@ -1,5 +1,6 @@
#include "BuildingSystem.h" #include "BuildingSystem.h"
#include <algorithm>
#include <cassert> #include <cassert>
#include <limits> #include <limits>
#include <random> #include <random>
@@ -9,6 +10,18 @@
#include "SurfaceMask.h" #include "SurfaceMask.h"
#include "tracing.h" #include "tracing.h"
namespace
{
// Smelter and Reprocessing Plant have no player-selected recipe
// (REQ-BLD-SMELTER, REQ-BLD-REPROCESSING). They auto-process whatever inputs
// they receive, matching against every recipe of their building type.
bool isAutoRecipeBuildingType(BuildingType type)
{
return type == BuildingType::Smelter
|| type == BuildingType::ReprocessingPlant;
}
} // namespace
BuildingSystem::BuildingSystem(const GameConfig& config, BuildingSystem::BuildingSystem(const GameConfig& config,
BeltSystem& belts, BeltSystem& belts,
std::function<BuildingId()> allocateBuildingId, std::function<BuildingId()> allocateBuildingId,
@@ -118,6 +131,56 @@ void BuildingSystem::initBuffers(Building& b, const RecipeDef& recipe) const
} }
} }
void BuildingSystem::initAutoBuffers(Building& b) const
{
b.inputBuffer.counts.clear();
b.inputBuffer.caps.clear();
// Union the inputs of every recipe of this building type; the cap for each
// item is twice the largest per-cycle requirement across those recipes.
// Output capacity follows the same rules as initBuffers: the Reprocessing
// Plant holds one cycle's max output (REQ-MAT-OUTPUT-BUFFER-REPROCESSING),
// other auto buildings hold twice the largest per-cycle output.
int outputCapacity = 0;
for (const RecipeDef& recipe : m_config.recipes.recipes)
{
if (recipe.building != b.type)
{
continue;
}
for (const RecipeIngredient& ing : recipe.inputs)
{
const ItemType type{ing.item};
b.inputBuffer.counts[type] = 0;
b.inputBuffer.caps[type] =
std::max(b.inputBuffer.caps[type], 2 * ing.amount);
}
if (b.type == BuildingType::ReprocessingPlant)
{
int maxAmount = 0;
for (const RecipeOutput& out : recipe.outputs)
{
maxAmount = std::max(maxAmount, out.amount);
}
outputCapacity = std::max(outputCapacity, maxAmount);
}
else
{
int totalAmount = 0;
for (const RecipeOutput& out : recipe.outputs)
{
totalAmount += out.amount;
}
outputCapacity = std::max(outputCapacity, 2 * totalAmount);
}
}
b.outputBuffer.items.clear();
b.outputBuffer.capacity = outputCapacity;
}
void BuildingSystem::initShipyardBuffers(Building& b) const void BuildingSystem::initShipyardBuffers(Building& b) const
{ {
b.inputBuffer.counts.clear(); b.inputBuffer.counts.clear();
@@ -419,6 +482,12 @@ void BuildingSystem::setRecipe(BuildingId id, const std::string& recipeId)
{ {
if (site.id == id) if (site.id == id)
{ {
// Auto-recipe buildings have no player-selected recipe
// (REQ-BLD-SMELTER, REQ-BLD-REPROCESSING); ignore any attempt to set one.
if (isAutoRecipeBuildingType(site.type))
{
return;
}
// No-op if the recipe is unchanged, so a redundant selection does // No-op if the recipe is unchanged, so a redundant selection does
// not wipe an already-configured ship layout. // not wipe an already-configured ship layout.
if (site.recipeId == recipeId) if (site.recipeId == recipeId)
@@ -436,6 +505,12 @@ void BuildingSystem::setRecipe(BuildingId id, const std::string& recipeId)
{ {
if (building.id == id) if (building.id == id)
{ {
// Auto-recipe buildings have no player-selected recipe
// (REQ-BLD-SMELTER, REQ-BLD-REPROCESSING); ignore any attempt to set one.
if (isAutoRecipeBuildingType(building.type))
{
return;
}
// No-op if the recipe is unchanged, so a redundant selection does // No-op if the recipe is unchanged, so a redundant selection does
// not wipe an already-configured ship layout or reset buffers. // not wipe an already-configured ship layout or reset buffers.
if (building.recipeId == recipeId) if (building.recipeId == recipeId)
@@ -603,6 +678,12 @@ void BuildingSystem::tickConstruction(Tick currentTick)
{ {
initSalvageBayBuffer(building); initSalvageBayBuffer(building);
} }
else if (isAutoRecipeBuildingType(building.type))
{
// Smelter/Reprocessing Plant need no recipe selection; buffers are set
// up from all recipes of the type (REQ-BLD-SMELTER, REQ-BLD-REPROCESSING).
initAutoBuffers(building);
}
else if (!building.recipeId.empty()) else if (!building.recipeId.empty())
{ {
if (building.type == BuildingType::Shipyard) if (building.type == BuildingType::Shipyard)
@@ -684,18 +765,24 @@ void BuildingSystem::tickBeltPull()
continue; continue;
} }
if (building.recipeId.empty()) // Auto-recipe buildings (Smelter, Reprocessing Plant) accept any item
// that is an input to one of their recipes; their caps already span the
// union of those inputs (initAutoBuffers), so no recipe lookup is needed.
if (!isAutoRecipeBuildingType(building.type))
{ {
continue; if (building.recipeId.empty())
}
if (building.type != BuildingType::Shipyard)
{
const RecipeDef* recipe = findRecipe(building.recipeId, building.type);
if (!recipe || recipe->inputs.empty())
{ {
continue; continue;
} }
if (building.type != BuildingType::Shipyard)
{
const RecipeDef* recipe = findRecipe(building.recipeId, building.type);
if (!recipe || recipe->inputs.empty())
{
continue;
}
}
} }
for (const Port& port : building.inputPorts) for (const Port& port : building.inputPorts)
@@ -752,18 +839,15 @@ void BuildingSystem::tickProduction(Tick currentTick)
continue; continue;
} }
if (building.recipeId.empty()) const bool autoRecipe = isAutoRecipeBuildingType(building.type);
if (!autoRecipe && building.recipeId.empty())
{ {
continue; continue;
} }
const RecipeDef* recipe = findRecipe(building.recipeId, building.type); // If a production cycle is active, check for completion. Completion only
if (!recipe) // needs the already-decided outputs, so it does not depend on which
{ // recipe is selected or auto-chosen.
continue;
}
// If a production cycle is active, check for completion.
if (building.production) if (building.production)
{ {
if (currentTick >= building.production->completesAt) if (currentTick >= building.production->completesAt)
@@ -779,66 +863,93 @@ void BuildingSystem::tickProduction(Tick currentTick)
continue; continue;
} }
// Idle: check if a new cycle can start. // Idle: gather the candidate recipes to try. Auto-recipe buildings
// (Smelter, Reprocessing Plant) have no selected recipe and try every
// 1. All required inputs present? // recipe of their type in config order, running the first whose inputs
bool inputsOk = true; // are satisfied (REQ-BLD-SMELTER, REQ-BLD-REPROCESSING). Other buildings
for (const RecipeIngredient& ing : recipe->inputs) // try only their selected recipe.
std::vector<const RecipeDef*> candidates;
if (autoRecipe)
{ {
const ItemType type{ing.item}; for (const RecipeDef& r : m_config.recipes.recipes)
const std::map<ItemType, int>::const_iterator it =
building.inputBuffer.counts.find(type);
const int have = (it != building.inputBuffer.counts.end()) ? it->second : 0;
if (have < ing.amount)
{ {
inputsOk = false; if (r.building == building.type && !r.inputs.empty())
break;
}
}
if (!inputsOk)
{
continue;
}
// 2. Determine chosen outputs (roll for reprocessing).
std::vector<Item> chosen;
if (building.type == BuildingType::ReprocessingPlant)
{
chosen = rollReprocessingOutput(*recipe);
if (chosen.empty()) { continue; }
}
else
{
for (const RecipeOutput& out : recipe->outputs)
{
Item item;
item.type.id = out.item;
for (int i = 0; i < out.amount; ++i)
{ {
chosen.push_back(item); candidates.push_back(&r);
} }
} }
} }
else
// 3. Output buffer has space for chosen outputs?
const int newSize = static_cast<int>(building.outputBuffer.items.size())
+ static_cast<int>(chosen.size());
if (newSize > building.outputBuffer.capacity)
{ {
continue; const RecipeDef* recipe = findRecipe(building.recipeId, building.type);
if (recipe)
{
candidates.push_back(recipe);
}
} }
// 4. Consume inputs and start cycle. for (const RecipeDef* recipe : candidates)
for (const RecipeIngredient& ing : recipe->inputs)
{ {
building.inputBuffer.counts[ItemType{ing.item}] -= ing.amount; // 1. All required inputs present?
} bool inputsOk = true;
for (const RecipeIngredient& ing : recipe->inputs)
{
const ItemType type{ing.item};
const std::map<ItemType, int>::const_iterator it =
building.inputBuffer.counts.find(type);
const int have = (it != building.inputBuffer.counts.end()) ? it->second : 0;
if (have < ing.amount)
{
inputsOk = false;
break;
}
}
if (!inputsOk)
{
continue;
}
Production prod; // 2. Determine chosen outputs (roll for reprocessing).
prod.recipeId = building.recipeId; std::vector<Item> chosen;
prod.completesAt = currentTick + secondsToTicks(recipe->durationSeconds); if (building.type == BuildingType::ReprocessingPlant)
prod.chosenOutputs = std::move(chosen); {
building.production = std::move(prod); chosen = rollReprocessingOutput(*recipe);
if (chosen.empty()) { continue; }
}
else
{
for (const RecipeOutput& out : recipe->outputs)
{
Item item;
item.type.id = out.item;
for (int i = 0; i < out.amount; ++i)
{
chosen.push_back(item);
}
}
}
// 3. Output buffer has space for chosen outputs?
const int newSize = static_cast<int>(building.outputBuffer.items.size())
+ static_cast<int>(chosen.size());
if (newSize > building.outputBuffer.capacity)
{
continue;
}
// 4. Consume inputs and start cycle.
for (const RecipeIngredient& ing : recipe->inputs)
{
building.inputBuffer.counts[ItemType{ing.item}] -= ing.amount;
}
Production prod;
prod.recipeId = recipe->id;
prod.completesAt = currentTick + secondsToTicks(recipe->durationSeconds);
prod.chosenOutputs = std::move(chosen);
building.production = std::move(prod);
break; // At most one cycle starts per tick.
}
} }
} }

View File

@@ -169,6 +169,10 @@ private:
const ShipDef* findShipDef(const std::string& id) const; const ShipDef* findShipDef(const std::string& id) const;
const ModuleDef* findModuleDef(const std::string& id) const; const ModuleDef* findModuleDef(const std::string& id) const;
void initBuffers(Building& b, const RecipeDef& recipe) const; void initBuffers(Building& b, const RecipeDef& recipe) const;
// Buffers for an auto-recipe building (Smelter, Reprocessing Plant): input
// caps span the union of every recipe of the building's type; no player
// recipe is selected (REQ-BLD-SMELTER, REQ-BLD-REPROCESSING).
void initAutoBuffers(Building& b) const;
void initShipyardBuffers(Building& b) const; void initShipyardBuffers(Building& b) const;
void initSalvageBayBuffer(Building& b) const; void initSalvageBayBuffer(Building& b) const;
std::vector<Port> computeInputPorts(const Building& b) const; std::vector<Port> computeInputPorts(const Building& b) const;

View File

@@ -474,7 +474,8 @@ TEST_CASE("BuildingSystem: productionBuildingCount excludes construction sites",
runTicks(bs, belts, static_cast<int>(secondsToTicks(15.0)), tick); runTicks(bs, belts, static_cast<int>(secondsToTicks(15.0)), tick);
REQUIRE(bs.productionBuildingCount() == 2); REQUIRE(bs.productionBuildingCount() == 2);
// Neither has a recipe selected, so neither has an active cycle. // Neither is producing yet: the miner has no recipe selected, and the
// smelter (auto-recipe, REQ-BLD-SMELTER) has no input feeding it.
REQUIRE(bs.activeProductionBuildingCount() == 0); REQUIRE(bs.activeProductionBuildingCount() == 0);
bs.setRecipe(minerId, "mine_iron_ore"); bs.setRecipe(minerId, "mine_iron_ore");
@@ -542,7 +543,8 @@ TEST_CASE("BuildingSystem: smelter input buffer fills from adjacent west-flowing
// Smelter mask ["AA ","AA>"] → body (0,0),(1,0),(0,1),(1,1). // Smelter mask ["AA ","AA>"] → body (0,0),(1,0),(0,1),(1,1).
// Output port (2,1) East. Input port example: (2,0) West. // Output port (2,1) East. Input port example: (2,0) West.
const BuildingId sid = bs.place(BuildingType::Smelter, QPoint(0, 0), Rotation::East, 0); const BuildingId sid = bs.place(BuildingType::Smelter, QPoint(0, 0), Rotation::East, 0);
bs.setRecipe(sid, "iron_ingot"); // Smelters have no recipe selection (REQ-BLD-SMELTER); they auto-accept any
// ore/scrap that is an input to a smelter recipe.
// Complete construction (15s → tick 450+1 = 451 ticks). // Complete construction (15s → tick 450+1 = 451 ticks).
Tick tick = 0; Tick tick = 0;
@@ -563,6 +565,105 @@ TEST_CASE("BuildingSystem: smelter input buffer fills from adjacent west-flowing
REQUIRE(it->second >= 1); REQUIRE(it->second >= 1);
} }
// A smelter auto-selects the matching recipe for whatever it is fed, with no
// player recipe selection (REQ-BLD-SMELTER).
TEST_CASE("BuildingSystem: smelter auto-smelts ore without a recipe selection",
"[building]")
{
const GameConfig cfg = loadConfig();
BeltSystem belts(static_cast<double>(kTickRateHz));
int stock = 0;
std::mt19937 rng(0);
BuildingId nextBuildingId = 1;
BuildingSystem bs(cfg, belts,
[&nextBuildingId]() { return nextBuildingId++; },
[&stock](int n) { stock += n; },
[](const std::string&, QVector2D, const std::optional<ShipLayoutConfig>&) {},
[](const std::string&) -> bool { return true; },
rng);
const BuildingId sid = bs.place(BuildingType::Smelter, QPoint(0, 0), Rotation::East, 0);
Tick tick = 0;
runTicks(bs, belts, static_cast<int>(secondsToTicks(15.0)) + 1, tick);
// Feed 2 iron_ore (the test-config iron_ingot recipe needs 2) via a
// west-flowing belt at input port (2,0).
belts.placeBelt(QPoint(2, 0), Rotation::West);
for (int i = 0; i < 2; ++i)
{
belts.tryPutItem(QPoint(2, 0), makeItem("iron_ore"));
belts.tick();
bs.tickBeltPull();
}
// iron_ingot recipe cycle is 2s; run to completion.
runTicks(bs, belts, static_cast<int>(secondsToTicks(2.0)) + 2, tick);
const Building* b = bs.findBuilding(sid);
REQUIRE(b != nullptr);
bool hasIronIngot = false;
for (const Item& item : b->outputBuffer.items)
{
if (item.type.id == "iron_ingot") { hasIronIngot = true; }
}
REQUIRE(hasIronIngot);
}
// With mixed inputs, the smelter runs whichever recipe is currently satisfiable
// and leaves an incomplete batch of another input waiting (see the union-of-
// inputs caps in initAutoBuffers).
TEST_CASE("BuildingSystem: smelter runs a satisfiable recipe while an incomplete batch waits",
"[building]")
{
const GameConfig cfg = loadConfig();
BeltSystem belts(static_cast<double>(kTickRateHz));
int stock = 0;
std::mt19937 rng(0);
BuildingId nextBuildingId = 1;
BuildingSystem bs(cfg, belts,
[&nextBuildingId]() { return nextBuildingId++; },
[&stock](int n) { stock += n; },
[](const std::string&, QVector2D, const std::optional<ShipLayoutConfig>&) {},
[](const std::string&) -> bool { return true; },
rng);
const BuildingId sid = bs.place(BuildingType::Smelter, QPoint(0, 0), Rotation::East, 0);
Tick tick = 0;
runTicks(bs, belts, static_cast<int>(secondsToTicks(15.0)) + 1, tick);
// Feed 1 iron_ore (iron_ingot needs 2 — incomplete) then 2 copper_ore
// (copper_ingot needs 2 — satisfiable) via the west-flowing input belt.
belts.placeBelt(QPoint(2, 0), Rotation::West);
const char* fed[] = { "iron_ore", "copper_ore", "copper_ore" };
for (const char* id : fed)
{
belts.tryPutItem(QPoint(2, 0), makeItem(id));
belts.tick();
bs.tickBeltPull();
}
// copper_ingot cycle is 2.5s; run to completion.
runTicks(bs, belts, static_cast<int>(secondsToTicks(2.5)) + 2, tick);
const Building* b = bs.findBuilding(sid);
REQUIRE(b != nullptr);
// Copper was smelted; the lone iron_ore still waits for a second unit.
bool hasCopperIngot = false;
for (const Item& item : b->outputBuffer.items)
{
if (item.type.id == "copper_ingot") { hasCopperIngot = true; }
}
REQUIRE(hasCopperIngot);
const std::map<ItemType, int>::const_iterator ironIt =
b->inputBuffer.counts.find(ItemType{"iron_ore"});
REQUIRE(ironIt != b->inputBuffer.counts.end());
REQUIRE(ironIt->second == 1);
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Belt push → belt tile // Belt push → belt tile
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -664,7 +765,8 @@ TEST_CASE("BuildingSystem: reprocessing plant output buffer capacity equals max
const BuildingId id = bs.place(BuildingType::ReprocessingPlant, const BuildingId id = bs.place(BuildingType::ReprocessingPlant,
QPoint(0, 0), Rotation::East, 0); QPoint(0, 0), Rotation::East, 0);
bs.setRecipe(id, "reprocessing_cycle"); // Reprocessing plants have no recipe selection (REQ-BLD-REPROCESSING); the
// single reprocessing recipe is applied automatically on completion.
// Complete construction (25s). // Complete construction (25s).
Tick tick = 0; Tick tick = 0;
@@ -695,7 +797,8 @@ TEST_CASE("BuildingSystem: reprocessing plant produces one cycle output then sta
const BuildingId id = bs.place(BuildingType::ReprocessingPlant, const BuildingId id = bs.place(BuildingType::ReprocessingPlant,
QPoint(0, 0), Rotation::East, 0); QPoint(0, 0), Rotation::East, 0);
bs.setRecipe(id, "reprocessing_cycle"); // Reprocessing plants have no recipe selection (REQ-BLD-REPROCESSING); the
// single reprocessing recipe is applied automatically on completion.
// Complete construction (25s). // Complete construction (25s).
Tick tick = 0; Tick tick = 0;

View File

@@ -78,6 +78,25 @@ bool isProductionBuilding(BuildingType type)
|| type == BuildingType::Shipyard; || type == BuildingType::Shipyard;
} }
// Buildings that expose a player recipe/schematic selection control
// (REQ-UI-SELECT-BUTTON): Miner ore type, Assembler recipe, Shipyard schematic.
// The Smelter and Reprocessing Plant auto-process and offer no selection
// (REQ-BLD-SMELTER, REQ-BLD-REPROCESSING).
bool hasRecipeSelection(BuildingType type)
{
return type == BuildingType::Miner
|| type == BuildingType::Assembler
|| type == BuildingType::Shipyard;
}
// Auto-recipe buildings have no selected recipe; their production is driven by
// whatever inputs they receive.
bool isAutoRecipeBuilding(BuildingType type)
{
return type == BuildingType::Smelter
|| type == BuildingType::ReprocessingPlant;
}
bool isBeltLike(BuildingType type) bool isBeltLike(BuildingType type)
{ {
return type == BuildingType::Belt || type == BuildingType::Splitter return type == BuildingType::Belt || type == BuildingType::Splitter
@@ -265,7 +284,7 @@ void SelectedBuildingPanel::buildSingle(BuildingId id)
m_titleLabel->show(); m_titleLabel->show();
m_buffersLabel->show(); m_buffersLabel->show();
if (isProductionBuilding(type)) if (hasRecipeSelection(type))
{ {
const std::vector<RecipeSelectionOption> options = const std::vector<RecipeSelectionOption> options =
buildRecipeSelectionOptions(type, *m_sim, *m_config); buildRecipeSelectionOptions(type, *m_sim, *m_config);
@@ -383,6 +402,21 @@ void SelectedBuildingPanel::refreshBuffers(const Building* b)
? findShipDef(b->recipeId) ? findShipDef(b->recipeId)
: nullptr; : nullptr;
// Auto-recipe buildings (Smelter, Reprocessing Plant) have no selected
// recipe; while a cycle runs, resolve the recipe actually in production so
// the cycle time and progress can be shown (REQ-UI-PRODUCTION-PROGRESS).
if (!recipe && isAutoRecipeBuilding(b->type) && b->production.has_value())
{
for (const RecipeDef& r : m_config->recipes.recipes)
{
if (r.id == b->production->recipeId && r.building == b->type)
{
recipe = &r;
break;
}
}
}
QString bufText; QString bufText;
if (!b->inputBuffer.counts.empty()) if (!b->inputBuffer.counts.empty())
@@ -469,41 +503,51 @@ void SelectedBuildingPanel::refreshBuffers(const Building* b)
} }
} }
if (isProductionBuilding(b->type) && (recipe || shipDef)) if (isProductionBuilding(b->type)
&& (recipe || shipDef || isAutoRecipeBuilding(b->type)))
{ {
double durationSeconds = recipe if (recipe || shipDef)
? recipe->durationSeconds
: shipDef->schematic.productionTimeSeconds;
if (shipDef && b->shipLayout.has_value())
{ {
for (const PlacedModule& pm : b->shipLayout->placedModules) double durationSeconds = recipe
? recipe->durationSeconds
: shipDef->schematic.productionTimeSeconds;
if (shipDef && b->shipLayout.has_value())
{ {
for (const ModuleDef& modDef : m_config->modules.modules) for (const PlacedModule& pm : b->shipLayout->placedModules)
{ {
if (modDef.id == pm.moduleId) for (const ModuleDef& modDef : m_config->modules.modules)
{ {
durationSeconds += modDef.productionTimeSeconds; if (modDef.id == pm.moduleId)
break; {
durationSeconds += modDef.productionTimeSeconds;
break;
}
} }
} }
} }
}
bufText += tr("Cycle: %1 s\n").arg(durationSeconds, 0, 'f', 1); bufText += tr("Cycle: %1 s\n").arg(durationSeconds, 0, 'f', 1);
if (b->production.has_value()) if (b->production.has_value())
{ {
const Tick cycleTicks = secondsToTicks(durationSeconds); const Tick cycleTicks = secondsToTicks(durationSeconds);
const Tick completesAt = b->production->completesAt; const Tick completesAt = b->production->completesAt;
const Tick currentTick = m_sim->currentTick(); const Tick currentTick = m_sim->currentTick();
const Tick elapsed = currentTick - (completesAt - cycleTicks); const Tick elapsed = currentTick - (completesAt - cycleTicks);
const int pct = static_cast<int>( const int pct = static_cast<int>(
std::max(Tick(0), std::min(cycleTicks, elapsed)) * 100 / cycleTicks); std::max(Tick(0), std::min(cycleTicks, elapsed)) * 100 / cycleTicks);
bufText += tr("Progress: %1%\n").arg(pct); bufText += tr("Progress: %1%\n").arg(pct);
}
else
{
bufText += tr("Progress: idle\n");
}
} }
else else
{ {
// Auto-recipe building with no active cycle: no single recipe to
// show a cycle time for.
bufText += tr("Progress: idle\n"); bufText += tr("Progress: idle\n");
} }
} }