From 2a4bd0b0668cd390d5d7ae9bde317ca7e4044369 Mon Sep 17 00:00:00 2001 From: Malte Langkabel Date: Sun, 12 Jul 2026 21:30:30 +0200 Subject: [PATCH] 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 Claude-Session: https://claude.ai/code/session_01DZR44tA8sn4dPqDzAVXyps --- src/lib/sim/BuildingSystem.cpp | 241 ++++++++++++++++++++++--------- src/lib/sim/BuildingSystem.h | 4 + src/test/BuildingTest.cpp | 111 +++++++++++++- src/ui/SelectedBuildingPanel.cpp | 90 +++++++++--- 4 files changed, 354 insertions(+), 92 deletions(-) diff --git a/src/lib/sim/BuildingSystem.cpp b/src/lib/sim/BuildingSystem.cpp index 3ef9c2c..2cffde0 100644 --- a/src/lib/sim/BuildingSystem.cpp +++ b/src/lib/sim/BuildingSystem.cpp @@ -1,5 +1,6 @@ #include "BuildingSystem.h" +#include #include #include #include @@ -9,6 +10,18 @@ #include "SurfaceMask.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, BeltSystem& belts, std::function 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 { b.inputBuffer.counts.clear(); @@ -419,6 +482,12 @@ void BuildingSystem::setRecipe(BuildingId id, const std::string& recipeId) { 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 // not wipe an already-configured ship layout. if (site.recipeId == recipeId) @@ -436,6 +505,12 @@ void BuildingSystem::setRecipe(BuildingId id, const std::string& recipeId) { 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 // not wipe an already-configured ship layout or reset buffers. if (building.recipeId == recipeId) @@ -603,6 +678,12 @@ void BuildingSystem::tickConstruction(Tick currentTick) { 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()) { if (building.type == BuildingType::Shipyard) @@ -684,18 +765,24 @@ void BuildingSystem::tickBeltPull() 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.type != BuildingType::Shipyard) - { - const RecipeDef* recipe = findRecipe(building.recipeId, building.type); - if (!recipe || recipe->inputs.empty()) + if (building.recipeId.empty()) { 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) @@ -752,18 +839,15 @@ void BuildingSystem::tickProduction(Tick currentTick) continue; } - if (building.recipeId.empty()) + const bool autoRecipe = isAutoRecipeBuildingType(building.type); + if (!autoRecipe && building.recipeId.empty()) { continue; } - const RecipeDef* recipe = findRecipe(building.recipeId, building.type); - if (!recipe) - { - continue; - } - - // If a production cycle is active, check for completion. + // If a production cycle is active, check for completion. Completion only + // needs the already-decided outputs, so it does not depend on which + // recipe is selected or auto-chosen. if (building.production) { if (currentTick >= building.production->completesAt) @@ -779,66 +863,93 @@ void BuildingSystem::tickProduction(Tick currentTick) continue; } - // Idle: check if a new cycle can start. - - // 1. All required inputs present? - bool inputsOk = true; - for (const RecipeIngredient& ing : recipe->inputs) + // Idle: gather the candidate recipes to try. Auto-recipe buildings + // (Smelter, Reprocessing Plant) have no selected recipe and try every + // recipe of their type in config order, running the first whose inputs + // are satisfied (REQ-BLD-SMELTER, REQ-BLD-REPROCESSING). Other buildings + // try only their selected recipe. + std::vector candidates; + if (autoRecipe) { - const ItemType type{ing.item}; - const std::map::const_iterator it = - building.inputBuffer.counts.find(type); - const int have = (it != building.inputBuffer.counts.end()) ? it->second : 0; - if (have < ing.amount) + for (const RecipeDef& r : m_config.recipes.recipes) { - inputsOk = false; - break; - } - } - if (!inputsOk) - { - continue; - } - - // 2. Determine chosen outputs (roll for reprocessing). - std::vector 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) + if (r.building == building.type && !r.inputs.empty()) { - chosen.push_back(item); + candidates.push_back(&r); } } } - - // 3. Output buffer has space for chosen outputs? - const int newSize = static_cast(building.outputBuffer.items.size()) - + static_cast(chosen.size()); - if (newSize > building.outputBuffer.capacity) + else { - continue; + const RecipeDef* recipe = findRecipe(building.recipeId, building.type); + if (recipe) + { + candidates.push_back(recipe); + } } - // 4. Consume inputs and start cycle. - for (const RecipeIngredient& ing : recipe->inputs) + for (const RecipeDef* recipe : candidates) { - 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::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; - prod.recipeId = building.recipeId; - prod.completesAt = currentTick + secondsToTicks(recipe->durationSeconds); - prod.chosenOutputs = std::move(chosen); - building.production = std::move(prod); + // 2. Determine chosen outputs (roll for reprocessing). + std::vector 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); + } + } + } + + // 3. Output buffer has space for chosen outputs? + const int newSize = static_cast(building.outputBuffer.items.size()) + + static_cast(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. + } } } diff --git a/src/lib/sim/BuildingSystem.h b/src/lib/sim/BuildingSystem.h index c3e80bd..98274e0 100644 --- a/src/lib/sim/BuildingSystem.h +++ b/src/lib/sim/BuildingSystem.h @@ -169,6 +169,10 @@ private: const ShipDef* findShipDef(const std::string& id) const; const ModuleDef* findModuleDef(const std::string& id) 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 initSalvageBayBuffer(Building& b) const; std::vector computeInputPorts(const Building& b) const; diff --git a/src/test/BuildingTest.cpp b/src/test/BuildingTest.cpp index 8a8b09b..7b2c5bc 100644 --- a/src/test/BuildingTest.cpp +++ b/src/test/BuildingTest.cpp @@ -474,7 +474,8 @@ TEST_CASE("BuildingSystem: productionBuildingCount excludes construction sites", runTicks(bs, belts, static_cast(secondsToTicks(15.0)), tick); 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); 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). // Output port (2,1) East. Input port example: (2,0) West. 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). Tick tick = 0; @@ -563,6 +565,105 @@ TEST_CASE("BuildingSystem: smelter input buffer fills from adjacent west-flowing 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(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&) {}, + [](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(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(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(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&) {}, + [](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(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(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::const_iterator ironIt = + b->inputBuffer.counts.find(ItemType{"iron_ore"}); + REQUIRE(ironIt != b->inputBuffer.counts.end()); + REQUIRE(ironIt->second == 1); +} + // --------------------------------------------------------------------------- // 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, 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). 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, 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). Tick tick = 0; diff --git a/src/ui/SelectedBuildingPanel.cpp b/src/ui/SelectedBuildingPanel.cpp index d63a716..eb1a7c5 100644 --- a/src/ui/SelectedBuildingPanel.cpp +++ b/src/ui/SelectedBuildingPanel.cpp @@ -78,6 +78,25 @@ bool isProductionBuilding(BuildingType type) || 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) { return type == BuildingType::Belt || type == BuildingType::Splitter @@ -265,7 +284,7 @@ void SelectedBuildingPanel::buildSingle(BuildingId id) m_titleLabel->show(); m_buffersLabel->show(); - if (isProductionBuilding(type)) + if (hasRecipeSelection(type)) { const std::vector options = buildRecipeSelectionOptions(type, *m_sim, *m_config); @@ -383,6 +402,21 @@ void SelectedBuildingPanel::refreshBuffers(const Building* b) ? findShipDef(b->recipeId) : 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; 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 - ? recipe->durationSeconds - : shipDef->schematic.productionTimeSeconds; - - if (shipDef && b->shipLayout.has_value()) + if (recipe || shipDef) { - 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; - break; + if (modDef.id == pm.moduleId) + { + 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()) - { - const Tick cycleTicks = secondsToTicks(durationSeconds); - const Tick completesAt = b->production->completesAt; - const Tick currentTick = m_sim->currentTick(); - const Tick elapsed = currentTick - (completesAt - cycleTicks); - const int pct = static_cast( - std::max(Tick(0), std::min(cycleTicks, elapsed)) * 100 / cycleTicks); - bufText += tr("Progress: %1%\n").arg(pct); + if (b->production.has_value()) + { + const Tick cycleTicks = secondsToTicks(durationSeconds); + const Tick completesAt = b->production->completesAt; + const Tick currentTick = m_sim->currentTick(); + const Tick elapsed = currentTick - (completesAt - cycleTicks); + const int pct = static_cast( + std::max(Tick(0), std::min(cycleTicks, elapsed)) * 100 / cycleTicks); + bufText += tr("Progress: %1%\n").arg(pct); + } + else + { + bufText += tr("Progress: idle\n"); + } } else { + // Auto-recipe building with no active cycle: no single recipe to + // show a cycle time for. bufText += tr("Progress: idle\n"); } }