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"); } }