diff --git a/src/lib/core/BuildingType.cpp b/src/lib/core/BuildingType.cpp index 8682360..a5068da 100644 --- a/src/lib/core/BuildingType.cpp +++ b/src/lib/core/BuildingType.cpp @@ -59,6 +59,8 @@ bool isConfigurableBuildingType(BuildingType type) { case BuildingType::Miner: // recipe (REQ-BLD-MINER) case BuildingType::Assembler: // recipe (REQ-BLD-ASSEMBLER) + case BuildingType::Smelter: // recipe (REQ-BLD-AUTO-RECIPE) + case BuildingType::ReprocessingPlant: // recipe (REQ-BLD-AUTO-RECIPE) case BuildingType::Shipyard: // schematic and layout (REQ-BLD-SHIPYARD, REQ-MOD-LAYOUT) case BuildingType::Splitter: // output filters (REQ-BLD-SPLITTER) return true; diff --git a/src/lib/core/BuildingType.h b/src/lib/core/BuildingType.h index a5c648e..994443a 100644 --- a/src/lib/core/BuildingType.h +++ b/src/lib/core/BuildingType.h @@ -30,9 +30,9 @@ std::optional parseBuildingType(const std::string& id); // Canonical id string for a BuildingType. The inverse of parseBuildingType. std::string buildingTypeId(BuildingType type); -// 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. +// Smelter and Reprocessing Plant pick a recipe for themselves from the first material +// offered to them while they have none (REQ-BLD-AUTO-RECIPE). In every other respect +// their recipe is selected and held exactly as any other building's. bool isAutoRecipeBuildingType(BuildingType type); // Buildings that run a production cycle: Miner, Smelter, Assembler, Reprocessing @@ -45,7 +45,9 @@ bool isProductionBuildingType(BuildingType type); bool isBeltSubsystemType(BuildingType type); // Building types with player-facing settings that a blueprint can carry and hand to an -// existing building (REQ-UI-BLUEPRINT-TRANSFER): Miner and Assembler (recipe), Shipyard -// (schematic and module layout), Splitter (output filters). Every other type has nothing -// to configure, so a blueprint of one has nothing to transfer. +// existing building (REQ-UI-BLUEPRINT-TRANSFER): Miner, Assembler, Smelter and +// Reprocessing Plant (recipe -- the last two select their own when they have none, +// REQ-BLD-AUTO-RECIPE), Shipyard (schematic and module layout), Splitter (output +// filters). Every other type has nothing to configure, so a blueprint of one has +// nothing to transfer. bool isConfigurableBuildingType(BuildingType type); diff --git a/src/lib/sim/BuildingBuffers.cpp b/src/lib/sim/BuildingBuffers.cpp index 25e94d4..90ba6e3 100644 --- a/src/lib/sim/BuildingBuffers.cpp +++ b/src/lib/sim/BuildingBuffers.cpp @@ -60,36 +60,6 @@ void initBuffers(Building& b, const RecipeDef& recipe) addOutputCaps(b.outputBuffer.caps, b.type, recipe); } -void initAutoBuffers(const GameConfig& config, Building& b) -{ - b.inputBuffer.counts.clear(); - b.inputBuffer.caps.clear(); - - b.outputBuffer.items.clear(); - b.outputBuffer.caps.clear(); - - // Union both sides over every recipe of this building type: the cap for each item is - // twice the largest per-cycle amount across those recipes, on the input side as on - // the output side (REQ-MAT-INPUT-BUFFER, REQ-MAT-OUTPUT-BUFFER). - for (const RecipeDef& recipe : 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); - } - - addOutputCaps(b.outputBuffer.caps, b.type, recipe); - } -} - void initShipyardBuffers(const GameConfig& config, Building& b) { b.inputBuffer.counts.clear(); diff --git a/src/lib/sim/BuildingBuffers.h b/src/lib/sim/BuildingBuffers.h index 417f0aa..cffb350 100644 --- a/src/lib/sim/BuildingBuffers.h +++ b/src/lib/sim/BuildingBuffers.h @@ -18,10 +18,6 @@ // REQ-MAT-OUTPUT-BUFFER). void initBuffers(Building& b, const RecipeDef& recipe); -// Buffers for an auto-recipe building (Smelter, Reprocessing Plant), unioned over -// every recipe of its type (REQ-BLD-SMELTER, REQ-BLD-REPROCESSING). -void initAutoBuffers(const GameConfig& config, Building& b); - // Buffers for a shipyard: its schematic's materials plus those of every placed // module (REQ-BLD-SHIPYARD). void initShipyardBuffers(const GameConfig& config, Building& b); diff --git a/src/lib/sim/BuildingSystem.cpp b/src/lib/sim/BuildingSystem.cpp index f63d412..bb37d5f 100644 --- a/src/lib/sim/BuildingSystem.cpp +++ b/src/lib/sim/BuildingSystem.cpp @@ -200,12 +200,6 @@ void BuildingSystem::setRecipe(FactoryState& state, BuildingId id, const std::st { 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) @@ -223,12 +217,6 @@ void BuildingSystem::setRecipe(FactoryState& state, BuildingId id, const std::st { 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) @@ -407,6 +395,10 @@ void BuildingSystem::tickBeltPull(FactoryState& state) { const std::optional peeked = m_belts.peekItem(building.inputPorts[i]); if (!peeked) { continue; } + // A Smelter or Reprocessing Plant without a recipe takes the first material + // offered to it as its selection (REQ-BLD-AUTO-RECIPE); the ports are walked + // in order, so which offer comes first is fixed. + selectAutoRecipeIfUnset(building, *peeked); if (!canAcceptInput(building, i, *peeked)) { continue; } const std::optional taken = m_belts.tryTakeItem(building.inputPorts[i]); if (taken) @@ -417,6 +409,25 @@ void BuildingSystem::tickBeltPull(FactoryState& state) } } +void BuildingSystem::selectAutoRecipeIfUnset(Building& building, const ItemType& offered) +{ + // Only while it holds none: once set, a recipe is the player's to change + // (REQ-BLD-AUTO-RECIPE). Buildings that select their own recipe are the only ones + // this applies to; everyone else ignores an offer they have no recipe for. + if (!building.recipeId.empty()) + { + return; + } + const RecipeDef* recipe = findAutoRecipeFor(m_config, building.type, offered); + if (!recipe) + { + return; + } + + building.recipeId = recipe->id; + initBuffers(building, *recipe); +} + bool BuildingSystem::canAcceptInput(const Building& consumer, std::size_t inputPortIndex, const ItemType& type) const @@ -477,6 +488,10 @@ bool BuildingSystem::tryDirectCoupleDeposit(FactoryState& state, BuildingId prod if (in.direction != outputPort.direction) { continue; } if (inputBodyTile(in.tile, in.direction) != outputPort.tile) { continue; } + // A coupling is an offer too, so an unset auto-recipe building selects from it + // (REQ-BLD-AUTO-RECIPE). Without this a Smelter placed flush against a producer + // would accept nothing and leave it stuck at its port for good. + selectAutoRecipeIfUnset(*consumer, item.type); if (!canAcceptInput(*consumer, j, item.type)) { return false; } depositToInputBelt(*consumer, j, item); return true; @@ -502,15 +517,14 @@ void BuildingSystem::tickProduction(FactoryState& state, Tick currentTick) continue; } - const bool autoRecipe = isAutoRecipeBuildingType(building.type); - if (!autoRecipe && building.recipeId.empty()) + if (building.recipeId.empty()) { continue; } // 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. + // recipe is selected. if (building.production) { if (currentTick < building.production->completesAt) @@ -531,68 +545,64 @@ void BuildingSystem::tickProduction(FactoryState& state, Tick currentTick) // waits, exactly as it would have on the following tick. } - // 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. - const std::vector candidates = - gatherCandidateRecipes(m_config, building); - - for (const RecipeDef* recipe : candidates) + // Idle: try to start the building's one selected recipe. Every type holds + // exactly one, a Smelter and a Reprocessing Plant included -- they differ only + // in how theirs first got set (REQ-BLD-AUTO-RECIPE). + const RecipeDef* recipe = getSelectedRecipe(m_config, building); + if (!recipe) { - // 1. All required inputs present? - if (!recipeInputsAvailable(building, *recipe)) - { - continue; - } + continue; + } - // 2. Room for every output this cycle could produce -- checked before - // anything is rolled (REQ-MAT-CYCLE). The roll below is committed the - // moment the cycle starts, so a plant that could not store some outcome - // must not start at all: that is what stops a stalled output belt from - // biasing the distribution towards the outputs that still fit. Emerging - // items count against their buffer (REQ-MAT-OUTPUT-EMERGE). The status - // light asks the same question to decide yellow (REQ-UI-STATUS-LIGHT), so - // the test lives in one place. - if (!recipeOutputsFit(building, *recipe)) - { - continue; - } + // 1. All required inputs present? + if (!recipeInputsAvailable(building, *recipe)) + { + continue; + } - // 3. Determine chosen outputs (roll for reprocessing). - std::vector chosen; - if (building.type == BuildingType::ReprocessingPlant) + // 2. Room for every output this cycle could produce -- checked before anything + // is rolled (REQ-MAT-CYCLE). The roll below is committed the moment the cycle + // starts, so a plant that could not store some outcome must not start at all: + // that is what stops a stalled output belt from biasing the distribution + // towards the outputs that still fit. Emerging items count against their + // buffer (REQ-MAT-OUTPUT-EMERGE). The status light asks the same question to + // decide yellow (REQ-UI-STATUS-LIGHT), so the test lives in one place. + if (!recipeOutputsFit(building, *recipe)) + { + continue; + } + + // 3. 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) { - 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) { - Item item; - item.type.id = out.item; - for (int i = 0; i < out.amount; ++i) - { - chosen.push_back(item); - } + chosen.push_back(item); } } - - // 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. } + + // 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); } } diff --git a/src/lib/sim/BuildingSystem.h b/src/lib/sim/BuildingSystem.h index f4b9f75..25f2f46 100644 --- a/src/lib/sim/BuildingSystem.h +++ b/src/lib/sim/BuildingSystem.h @@ -188,6 +188,13 @@ private: // (on construction completion, or when un-queuing a deconstruction). No-op for // non-belt-subsystem types. Splitter filters are (re)applied after placement. + // Selects a recipe for an auto-recipe building that has none, from a material being + // offered to it at one of its input ports (REQ-BLD-AUTO-RECIPE). No-op for every + // other building, for one that already holds a recipe, and for a material none of + // its recipes consumes. Called from both intake paths -- the belt pull and the + // direct coupling -- since either can be where the first material arrives. + void selectAutoRecipeIfUnset(Building& building, + const ItemType& offered); // True if the consumer would accept `type` at the given input port right now: // it is a required input (or a building block for the HQ), the reservation-aware // buffer has room, and the input belt entry is free (REQ-MAT-INPUT-INTAKE). diff --git a/src/lib/sim/ConstructionSystem.cpp b/src/lib/sim/ConstructionSystem.cpp index 7e04c30..7f4fbc7 100644 --- a/src/lib/sim/ConstructionSystem.cpp +++ b/src/lib/sim/ConstructionSystem.cpp @@ -67,12 +67,6 @@ void ConstructionSystem::tick(FactoryState& state, BeltSystem& belts, Tick curre { initSalvageBayBuffer(m_config, 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(m_config, building); - } else if (!building.recipeId.empty()) { if (building.type == BuildingType::Shipyard) diff --git a/src/lib/sim/ProductionRules.cpp b/src/lib/sim/ProductionRules.cpp index 7f26045..be6391c 100644 --- a/src/lib/sim/ProductionRules.cpp +++ b/src/lib/sim/ProductionRules.cpp @@ -8,29 +8,37 @@ #include "ModulesConfig.h" #include "ShipsConfig.h" -std::vector -gatherCandidateRecipes(const GameConfig& config, const Building& b) +const RecipeDef* getSelectedRecipe(const GameConfig& config, const Building& b) { - std::vector candidates; - if (isAutoRecipeBuildingType(b.type)) + if (b.recipeId.empty()) { - for (const RecipeDef& r : config.recipes.recipes) + return nullptr; + } + return config.recipes.findRecipeDef(b.recipeId, b.type); +} + +const RecipeDef* findAutoRecipeFor(const GameConfig& config, BuildingType type, + const ItemType& item) +{ + if (!isAutoRecipeBuildingType(type)) + { + return nullptr; + } + + // Config order decides where a material feeds more than one recipe of the type, so + // the same offer always picks the same recipe (REQ-BLD-AUTO-RECIPE). + for (const RecipeDef& recipe : config.recipes.recipes) + { + if (recipe.building != type) { continue; } + for (const RecipeIngredient& ing : recipe.inputs) { - if (r.building == b.type && !r.inputs.empty()) + if (ItemType{ing.item} == item) { - candidates.push_back(&r); + return &recipe; } } } - else - { - const RecipeDef* recipe = config.recipes.findRecipeDef(b.recipeId, b.type); - if (recipe) - { - candidates.push_back(recipe); - } - } - return candidates; + return nullptr; } bool recipeInputsAvailable(const Building& b, const RecipeDef& recipe) { @@ -130,17 +138,11 @@ bool hasInputsToStart(const GameConfig& config, const Building& b) return true; } - // Recipe buildings: startable if any candidate recipe's inputs are satisfied. - // A Miner recipe has no inputs, so an idle Miner is always startable here and its - // only idle reason is an output buffer without room for the next cycle. - for (const RecipeDef* recipe : gatherCandidateRecipes(config, b)) - { - if (recipeInputsAvailable(b, *recipe)) - { - return true; - } - } - return false; + // Recipe buildings: startable if the selected recipe's inputs are satisfied. A Miner + // recipe has no inputs, so an idle Miner is always startable here and its only idle + // reason is an output buffer without room for the next cycle. + const RecipeDef* recipe = getSelectedRecipe(config, b); + return recipe != nullptr && recipeInputsAvailable(b, *recipe); } bool outputBufferHasRoom(const Building& b, const ItemType& type, int itemCount) @@ -192,14 +194,9 @@ bool canStartCycle(const GameConfig& config, const Building& b) return hasInputsToStart(config, b); } - for (const RecipeDef* recipe : gatherCandidateRecipes(config, b)) - { - if (recipeInputsAvailable(b, *recipe) && recipeOutputsFit(b, *recipe)) - { - return true; - } - } - return false; + const RecipeDef* recipe = getSelectedRecipe(config, b); + return recipe != nullptr && recipeInputsAvailable(b, *recipe) + && recipeOutputsFit(b, *recipe); } std::optional @@ -220,9 +217,10 @@ getProductionStatus(const GameConfig& config, const Building& building) return std::nullopt; } - // Grey only applies to player-configured types; auto-recipe buildings - // (Smelter, Reprocessing Plant) always run an implicit recipe. - if (!isAutoRecipeBuildingType(building.type) && building.recipeId.empty()) + // Every production building can be unconfigured, an auto-recipe building included: + // it holds no recipe until one is offered to it, and the player can hand it back to + // automatic selection (REQ-BLD-AUTO-RECIPE, REQ-UI-STATUS-LIGHT). + if (building.recipeId.empty()) { return ProductionStatus::Unconfigured; } diff --git a/src/lib/sim/ProductionRules.h b/src/lib/sim/ProductionRules.h index a3e8b4a..61d36b9 100644 --- a/src/lib/sim/ProductionRules.h +++ b/src/lib/sim/ProductionRules.h @@ -25,10 +25,16 @@ enum class ProductionStatus // Pure functions of the config and the building itself — they read no factory // state, so they are free functions rather than BuildingSystem members. -// Recipes this building could run: every recipe of its type for an auto-recipe -// building (REQ-BLD-SMELTER, REQ-BLD-REPROCESSING), otherwise just its selected one. -std::vector gatherCandidateRecipes(const GameConfig& config, - const Building& b); +// The recipe this building runs, or null when it has none selected. Every building type +// holds exactly one, a Smelter and a Reprocessing Plant included -- they only differ in +// how theirs first gets set (REQ-BLD-AUTO-RECIPE). +const RecipeDef* getSelectedRecipe(const GameConfig& config, const Building& b); + +// The recipe an auto-recipe building adopts when this material is offered to it while it +// has none: the first recipe of its type, in config order, that consumes the material +// (REQ-BLD-AUTO-RECIPE). Null when no recipe of the type takes it. +const RecipeDef* findAutoRecipeFor(const GameConfig& config, BuildingType type, + const ItemType& item); // True when the building's input buffer holds every ingredient the recipe needs. bool recipeInputsAvailable(const Building& b, const RecipeDef& recipe); diff --git a/src/test/BuildingTest.cpp b/src/test/BuildingTest.cpp index 3e3580b..650a42a 100644 --- a/src/test/BuildingTest.cpp +++ b/src/test/BuildingTest.cpp @@ -629,12 +629,13 @@ 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 = f.bs.place(f.state, BuildingType::Smelter, QPoint(0, 0), Rotation::East, 0).value(); - // Smelters have no recipe selection (REQ-BLD-SMELTER); they auto-accept any - // ore/scrap that is an input to a smelter recipe. + // A smelter starts with no recipe and picks one from the first material offered to + // it (REQ-BLD-AUTO-RECIPE), which is what lets it accept the ore below. // Complete construction (15s → tick 450+1 = 451 ticks). Tick tick = 0; runTicks(f.bs, f.cfg, f.state, f.belts, f.stock, static_cast(secondsToTicks(15.0)) + 1, tick); + REQUIRE(findBuilding(f.state, sid)->recipeId.empty()); // Place west-flowing belt at (2,0): belt flows West, delivers to smelter. f.belts.placeBelt(QPoint(2, 0), Rotation::West); @@ -645,6 +646,13 @@ TEST_CASE("BuildingSystem: smelter input buffer fills from adjacent west-flowing const Building* b = findBuilding(f.state, sid); REQUIRE(b != nullptr); + // The ore selected the recipe that consumes it, and its buffers were sized for that + // recipe alone -- copper ore is not one of its inputs any more. + REQUIRE(b->recipeId == "iron_ingot"); + REQUIRE(b->inputBuffer.caps.count(ItemType{"iron_ore"}) == 1); + REQUIRE(b->inputBuffer.caps.count(ItemType{"copper_ore"}) == 0); + REQUIRE(b->outputBuffer.caps.count(ItemType{"iron_ingot"}) == 1); + REQUIRE(b->outputBuffer.caps.count(ItemType{"copper_ingot"}) == 0); // The item was accepted; it may still be travelling inward on the input belt, // so count buffered + in-transit (REQ-MAT-INPUT-INTAKE). REQUIRE(b->pendingInputCount(ItemType{"iron_ore"}) >= 1); @@ -749,10 +757,10 @@ TEST_CASE("BuildingSystem: smelter auto-smelts ore without a recipe selection", 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", +// A belt carrying mixed ore is the realistic case for REQ-BLD-AUTO-RECIPE: the first ore +// to arrive settles the recipe, and everything else on that belt is refused rather than +// smelted alongside it. +TEST_CASE("BuildingSystem: mixed ore on one belt leaves the smelter on the first ore's recipe", "[building]") { PlacementFixture f(kFastBeltSpeed_tps); @@ -762,8 +770,7 @@ TEST_CASE("BuildingSystem: smelter runs a satisfiable recipe while an incomplete Tick tick = 0; runTicks(f.bs, f.cfg, f.state, f.belts, f.stock, 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. + // Feed 1 iron_ore, then 2 copper_ore, via the west-flowing input belt. f.belts.placeBelt(QPoint(2, 0), Rotation::West); const char* fed[] = { "iron_ore", "copper_ore", "copper_ore" }; for (const char* id : fed) @@ -773,24 +780,26 @@ TEST_CASE("BuildingSystem: smelter runs a satisfiable recipe while an incomplete f.bs.tickBeltPull(f.state); } - // copper_ingot cycle is 2.5s; run to completion. runTicks(f.bs, f.cfg, f.state, f.belts, f.stock, static_cast(secondsToTicks(2.5)) + 2, tick); const Building* b = findBuilding(f.state, sid); REQUIRE(b != nullptr); - // Copper was smelted; the lone iron_ore still waits for a second unit. - bool hasCopperIngot = false; + // The iron ore came first, so the smelter smelts iron and nothing else. The copper + // was never taken in, so no copper ingot was made. + REQUIRE(b->recipeId == "iron_ingot"); for (const Item& item : outputSideItems(*b)) { - if (item.type.id == "copper_ingot") { hasCopperIngot = true; } + REQUIRE(item.type.id != "copper_ingot"); } - REQUIRE(hasCopperIngot); + REQUIRE(b->pendingInputCount(ItemType{"copper_ore"}) == 0); + // The lone iron ore still waits for a second unit: the recipe needs two. const std::map::const_iterator ironIt = b->inputBuffer.counts.find(ItemType{"iron_ore"}); REQUIRE(ironIt != b->inputBuffer.counts.end()); REQUIRE(ironIt->second == 1); + REQUIRE_FALSE(b->production.has_value()); } // --------------------------------------------------------------------------- @@ -925,13 +934,16 @@ TEST_CASE("BuildingSystem: reprocessing plant sizes one output buffer per possib const BuildingId id = f.bs.place(f.state, BuildingType::ReprocessingPlant, QPoint(0, 0), Rotation::East, 0).value(); - // Reprocessing plants have no recipe selection (REQ-BLD-REPROCESSING); the - // single reprocessing recipe is applied automatically on completion. // Complete construction (25s). Tick tick = 0; runTicks(f.bs, f.cfg, f.state, f.belts, f.stock, static_cast(secondsToTicks(25.0)) + 1, tick); + // A plant holds no buffers until it has a recipe (REQ-BLD-AUTO-RECIPE); selecting + // one sizes them, exactly as the first scrap offered to it would. + REQUIRE(findBuilding(f.state, id)->outputBuffer.caps.empty()); + f.bs.setRecipe(f.state, id, "reprocessing_cycle"); + const Building* b = findBuilding(f.state, id); REQUIRE(b != nullptr); // reprocessing_cycle outputs: 2 iron_ingot (60%), 1 circuit_board (30%), @@ -1029,30 +1041,133 @@ TEST_CASE("BuildingSystem: reprocessing plant runs a second cycle while holding REQUIRE(getProductionStatus(f.cfg, *b) == ProductionStatus::Producing); } -TEST_CASE("BuildingSystem: one item's backlog does not block another item's cycle", +// --------------------------------------------------------------------------- +// Automatic recipe selection (REQ-BLD-AUTO-RECIPE) +// --------------------------------------------------------------------------- + +// Places a smelter and runs it to completion, leaving it with no recipe. +static BuildingId buildSmelter(PlacementFixture& f, QPoint anchor, Tick& tick) +{ + const BuildingId id = + f.bs.place(f.state, BuildingType::Smelter, anchor, Rotation::East, 0).value(); + runTicks(f.bs, f.cfg, f.state, f.belts, f.stock, + static_cast(secondsToTicks(15.0)) + 1, tick); + return id; +} + +TEST_CASE("BuildingSystem: an unset auto-recipe building is unconfigured", "[building]") +{ + // It holds no recipe until one is offered to it, so it reads grey like any other + // unconfigured building (REQ-BLD-AUTO-RECIPE, REQ-UI-STATUS-LIGHT). + PlacementFixture f; + Tick tick = 0; + const BuildingId id = buildSmelter(f, QPoint(0, 0), tick); + + const Building* b = findBuilding(f.state, id); + REQUIRE(b != nullptr); + REQUIRE(b->recipeId.empty()); + REQUIRE(b->inputBuffer.caps.empty()); + REQUIRE(b->outputBuffer.caps.empty()); + REQUIRE(getProductionStatus(f.cfg, *b) == ProductionStatus::Unconfigured); +} + +TEST_CASE("BuildingSystem: a set recipe is never replaced by a later material", "[building]") { - // Per-item buffers, so a smelter holding iron ingots can still smelt copper - // (REQ-MAT-OUTPUT-BUFFER). Under one shared capacity the iron would have blocked it. - PlacementFixture f; + // Once set the recipe is the player's to change: a material belonging to another of + // its recipes is simply not an accepted input (REQ-BLD-AUTO-RECIPE). + PlacementFixture f(kFastBeltSpeed_tps); + Tick tick = 0; + const BuildingId id = buildSmelter(f, QPoint(0, 0), tick); - Building smelter; - smelter.type = BuildingType::Smelter; - initAutoBuffers(f.cfg, smelter); + // Iron ore first, which selects the iron recipe. + f.belts.placeBelt(QPoint(2, 0), Rotation::West); + f.belts.tryPutItem(QPoint(2, 0), makeItem("iron_ore")); + f.belts.tick(); + f.bs.tickBeltPull(f.state); + REQUIRE(findBuilding(f.state, id)->recipeId == "iron_ingot"); - // Copper ore in, and the iron_ingot buffer filled to its cap. - smelter.inputBuffer.counts[ItemType{"copper_ore"}] = - smelter.inputBuffer.caps.at(ItemType{"copper_ore"}); - const int ironCap = smelter.outputBuffer.caps.at(ItemType{"iron_ingot"}); - for (int i = 0; i < ironCap; ++i) - { - smelter.outputBuffer.items.push_back(makeItem("iron_ingot")); - } + // Copper ore next: refused, and the recipe stands. + f.belts.tryPutItem(QPoint(2, 0), makeItem("copper_ore")); + f.belts.tick(); + f.bs.tickBeltPull(f.state); - REQUIRE_FALSE(outputBufferHasRoom(smelter, ItemType{"iron_ingot"}, 1)); - REQUIRE(outputBufferHasRoom(smelter, ItemType{"copper_ingot"}, 1)); - REQUIRE(canStartCycle(f.cfg, smelter)); - REQUIRE(getProductionStatus(f.cfg, smelter) == ProductionStatus::Producing); + const Building* b = findBuilding(f.state, id); + REQUIRE(b->recipeId == "iron_ingot"); + REQUIRE(b->pendingInputCount(ItemType{"copper_ore"}) == 0); + // The copper is still sitting on the belt, refused rather than swallowed. + REQUIRE(f.belts.peekItem(westPort(QPoint(2, 0))).has_value()); +} + +TEST_CASE("BuildingSystem: a manually selected recipe is not overridden", "[building]") +{ + // The player's selection is a recipe like any other, so auto-selection stays out of + // the way and the smelter refuses ore it does not smelt (REQ-BLD-AUTO-RECIPE). + PlacementFixture f(kFastBeltSpeed_tps); + Tick tick = 0; + const BuildingId id = buildSmelter(f, QPoint(0, 0), tick); + + f.bs.setRecipe(f.state, id, "copper_ingot"); + REQUIRE(findBuilding(f.state, id)->recipeId == "copper_ingot"); + + f.belts.placeBelt(QPoint(2, 0), Rotation::West); + f.belts.tryPutItem(QPoint(2, 0), makeItem("iron_ore")); + f.belts.tick(); + f.bs.tickBeltPull(f.state); + + const Building* b = findBuilding(f.state, id); + REQUIRE(b->recipeId == "copper_ingot"); + REQUIRE(b->pendingInputCount(ItemType{"iron_ore"}) == 0); +} + +TEST_CASE("BuildingSystem: selecting a different recipe frees a stuck auto-recipe building", + "[building]") +{ + // A smelter left holding part of a cycle nothing feeds any more is freed by + // selecting another recipe, which clears the buffers -- that is why no separate + // clear action exists (REQ-BLD-AUTO-RECIPE, REQ-MAT-INPUT-BUFFER). + PlacementFixture f(kFastBeltSpeed_tps); + Tick tick = 0; + const BuildingId id = buildSmelter(f, QPoint(0, 0), tick); + + // One iron ore, where the recipe needs two: it can never run. + f.belts.placeBelt(QPoint(2, 0), Rotation::West); + f.belts.tryPutItem(QPoint(2, 0), makeItem("iron_ore")); + f.belts.tick(); + f.bs.tickBeltPull(f.state); + runTicks(f.bs, f.cfg, f.state, f.belts, f.stock, 30, tick); + + const Building* stuck = findBuilding(f.state, id); + REQUIRE(stuck->recipeId == "iron_ingot"); + REQUIRE(stuck->pendingInputCount(ItemType{"iron_ore"}) == 1); + REQUIRE_FALSE(stuck->production.has_value()); + + f.bs.setRecipe(f.state, id, "copper_ingot"); + const Building* freed = findBuilding(f.state, id); + REQUIRE(freed->recipeId == "copper_ingot"); + REQUIRE(freed->pendingInputCount(ItemType{"iron_ore"}) == 0); + REQUIRE(freed->inputBuffer.caps.count(ItemType{"copper_ore"}) == 1); +} + +TEST_CASE("BuildingSystem: selecting (Auto) returns the building to automatic selection", + "[building]") +{ + // The dialog's clearing option unsets the recipe rather than leaving the building + // idle for good: the next material offered selects one again (REQ-BLD-AUTO-RECIPE). + PlacementFixture f(kFastBeltSpeed_tps); + Tick tick = 0; + const BuildingId id = buildSmelter(f, QPoint(0, 0), tick); + + f.bs.setRecipe(f.state, id, "copper_ingot"); + f.bs.setRecipe(f.state, id, std::string()); // the "(Auto)" option + REQUIRE(findBuilding(f.state, id)->recipeId.empty()); + + f.belts.placeBelt(QPoint(2, 0), Rotation::West); + f.belts.tryPutItem(QPoint(2, 0), makeItem("iron_ore")); + f.belts.tick(); + f.bs.tickBeltPull(f.state); + + REQUIRE(findBuilding(f.state, id)->recipeId == "iron_ingot"); } TEST_CASE("BuildingSystem: reprocessing plant produces one cycle output then stalls", @@ -1063,8 +1178,8 @@ TEST_CASE("BuildingSystem: reprocessing plant produces one cycle output then sta const BuildingId id = f.bs.place(f.state, BuildingType::ReprocessingPlant, QPoint(0, 0), Rotation::East, 0).value(); - // Reprocessing plants have no recipe selection (REQ-BLD-REPROCESSING); the - // single reprocessing recipe is applied automatically on completion. + // The plant selects its recipe from the first scrap offered to it + // (REQ-BLD-AUTO-RECIPE), which is what the belt feeding below does. // Complete construction (25s). Tick tick = 0; @@ -1242,10 +1357,12 @@ TEST_CASE("isConfigurableBuildingType: only types with player-facing settings", CHECK(isConfigurableBuildingType(BuildingType::Shipyard)); CHECK(isConfigurableBuildingType(BuildingType::Splitter)); - // Smelter and Reprocessing Plant run implicit recipes (REQ-BLD-SMELTER, - // REQ-BLD-REPROCESSING) and the rest have no settings whatsoever. - CHECK_FALSE(isConfigurableBuildingType(BuildingType::Smelter)); - CHECK_FALSE(isConfigurableBuildingType(BuildingType::ReprocessingPlant)); + // Smelter and Reprocessing Plant carry a recipe like any other, even though they can + // also select it themselves (REQ-BLD-AUTO-RECIPE). + CHECK(isConfigurableBuildingType(BuildingType::Smelter)); + CHECK(isConfigurableBuildingType(BuildingType::ReprocessingPlant)); + + // The rest have no settings whatsoever. CHECK_FALSE(isConfigurableBuildingType(BuildingType::SalvageBay)); CHECK_FALSE(isConfigurableBuildingType(BuildingType::Belt)); CHECK_FALSE(isConfigurableBuildingType(BuildingType::TunnelEntry)); @@ -1295,8 +1412,15 @@ TEST_CASE("resolveBlueprintGhost: a partial overlap of the same type is invalid" // Both footprints stay on the asteroid, so terrain is not what fails here. f.bs.place(f.state, BuildingType::Smelter, QPoint(-3, 0), Rotation::East, 0); - CHECK(resolveOne(f, BuildingType::Smelter, QPoint(-2, 0), Rotation::East).action + // Judged on where it sits, with no cursor to hit-test: the overlap is what decides. + CHECK(resolveInConstellation(f, BuildingType::Smelter, QPoint(-2, 0), Rotation::East).action == BlueprintGhostAction::Invalid); + + // With the cursor on the existing smelter the single-building gesture answers first + // and hands it the settings, since a smelter carries a recipe (REQ-BLD-AUTO-RECIPE, + // REQ-UI-BLUEPRINT-TRANSFER) -- the same as for a miner. + CHECK(resolveOne(f, BuildingType::Smelter, QPoint(-2, 0), Rotation::East).action + == BlueprintGhostAction::Transfer); } TEST_CASE("resolveBlueprintGhost: a single configurable building transfers its settings", @@ -1457,11 +1581,11 @@ TEST_CASE("resolveBlueprintGhost: a constellation mixes transfers and plain over REQUIRE(miner.action == BlueprintGhostAction::Transfer); CHECK(*miner.targetId == minerId); - // A smelter runs an implicit recipe (REQ-BLD-SMELTER), so there is nothing to hand - // over and it is simply left as it is. + // A smelter carries a recipe too now (REQ-BLD-AUTO-RECIPE), so a blueprint of one + // has something to hand over just as the miner does. const BlueprintGhostResolved smelter = resolveInConstellation(f, BuildingType::Smelter, QPoint(-5, 0), Rotation::East); - REQUIRE(smelter.action == BlueprintGhostAction::CompatibleOverlap); + REQUIRE(smelter.action == BlueprintGhostAction::Transfer); CHECK(*smelter.targetId == smelterId); } @@ -1803,6 +1927,7 @@ TEST_CASE("BuildingSystem: getProductionStatus classifies production state", "[b REQUIRE(reprocessingRecipe->outputs.size() >= 2); Building plant; plant.type = BuildingType::ReprocessingPlant; + plant.recipeId = reprocessingRecipe->id; initBuffers(plant, *reprocessingRecipe); for (const RecipeIngredient& ing : reprocessingRecipe->inputs) { @@ -1829,11 +1954,33 @@ TEST_CASE("BuildingSystem: getProductionStatus classifies production state", "[b REQUIRE(statusOf(plant) == ProductionStatus::Starved); } - SECTION("Smelter (auto-recipe) is never grey") + SECTION("Smelter: grey until it has a recipe, then judged like any other building") { + // It holds no recipe until one is offered to it, so grey now applies to it too + // (REQ-BLD-AUTO-RECIPE, REQ-UI-STATUS-LIGHT). Building smelter; smelter.type = BuildingType::Smelter; - // No player-selectable recipe and empty inputs -> red, not grey. - REQUIRE(statusOf(smelter) == ProductionStatus::Starved); + REQUIRE(statusOf(smelter) == ProductionStatus::Unconfigured); + + const RecipeDef* smelterRecipe = nullptr; + for (const RecipeDef& r : f.cfg.recipes.recipes) + { + if (r.building == BuildingType::Smelter && !r.inputs.empty()) + { + smelterRecipe = &r; + break; + } + } + REQUIRE(smelterRecipe != nullptr); + + smelter.recipeId = smelterRecipe->id; + initBuffers(smelter, *smelterRecipe); + REQUIRE(statusOf(smelter) == ProductionStatus::Starved); // recipe, but no ore + + for (const RecipeIngredient& ing : smelterRecipe->inputs) + { + smelter.inputBuffer.counts[ItemType{ing.item}] = ing.amount; + } + REQUIRE(statusOf(smelter) == ProductionStatus::Producing); } SECTION("Shipyard: unconfigured, then starved without materials, then producing") diff --git a/src/ui/ItemProducers.h b/src/ui/ItemProducers.h index 66c3277..ce8c5cc 100644 --- a/src/ui/ItemProducers.h +++ b/src/ui/ItemProducers.h @@ -27,7 +27,7 @@ struct ItemProduction // // What counts as available differs by building type, because only Miner and Assembler // recipes are individually unlocked (REQ-LOCK-UI-RECIPE): those are filtered by the -// unlock state, while a Smelter's or Reprocessing Plant's implicit recipes +// unlock state, while a Smelter's or Reprocessing Plant's recipes // (REQ-BLD-SMELTER, REQ-BLD-REPROCESSING) are filtered by whether their building is // unlocked yet (REQ-LOCK-BUILDING) -- there is no sense in naming a path through a // plant the player cannot place. diff --git a/src/ui/RecipeSelectionDialog.cpp b/src/ui/RecipeSelectionDialog.cpp index 9c996fa..5d5022e 100644 --- a/src/ui/RecipeSelectionDialog.cpp +++ b/src/ui/RecipeSelectionDialog.cpp @@ -50,7 +50,13 @@ std::vector buildRecipeSelectionOptions( BuildingType type, Simulation& sim, const GameConfig& config) { std::vector options; - options.push_back({std::string(), QObject::tr("(None)"), RecipeLineRow::Spec()}); + // The clearing option. On a building that picks its own recipe it does not leave the + // building idle but hands it back to that selection, and says so + // (REQ-UI-SELECT-OPTIONS, REQ-BLD-AUTO-RECIPE). + options.push_back({std::string(), + isAutoRecipeBuildingType(type) ? QObject::tr("(Auto)") + : QObject::tr("(None)"), + RecipeLineRow::Spec()}); if (type == BuildingType::Shipyard) { diff --git a/src/ui/selection/AutoProductionContent.cpp b/src/ui/selection/AutoProductionContent.cpp deleted file mode 100644 index 63a418f..0000000 --- a/src/ui/selection/AutoProductionContent.cpp +++ /dev/null @@ -1,75 +0,0 @@ -#include "AutoProductionContent.h" - -#include "Building.h" -#include "BuildingTarget.h" -#include "GameConfig.h" -#include "ProductionRules.h" - -AutoProductionContent::AutoProductionContent(const SelectionContext& context, - const SelectionRequest& request, - QWidget* parent) - : BufferedBuildingContent(context, request.buildings.front(), parent) -{ -} - -BufferedBuildingContent::CycleInfo AutoProductionContent::getCycleInfo( - const BuildingTarget& target) const -{ - CycleInfo info; - // An auto-recipe building always runs an implicit recipe (REQ-BLD-SMELTER, - // REQ-BLD-REPROCESSING), so its production section is always shown -- but only a - // running cycle names a recipe, so while it is idle there is no cycle to describe. - info.runsProduction = true; - - if (target.building) - { - // What the building handles at all, so its buffers are not blank whenever it - // happens to be between cycles (REQ-UI-SINGLE-SELECTION). This is the same union - // of every recipe of its type that the simulation sized the buffers over, and - // the locked ones among them are dropped when the card lists them. - for (const RecipeDef* recipe : - gatherCandidateRecipes(*getContext().config, *target.building)) - { - for (const RecipeIngredient& ingredient : recipe->inputs) - { - info.handledInputs.push_back(ingredient.item); - } - for (const RecipeOutput& output : recipe->outputs) - { - info.handledOutputs.push_back(output.item); - } - } - } - - // Which recipe describes the cycle: the one running, or -- between cycles -- the one - // that ran last. Dropping it while idle would take the summary row and the chips' - // per-cycle amounts away and bring them back with every cycle, resizing the card in - // step with the building's status (REQ-UI-RECIPE-SUMMARY). Only a building that has - // never run has nothing to describe. - if (target.building && target.building->production.has_value()) - { - m_lastRecipeId = target.building->production->recipeId; - } - if (!target.building || m_lastRecipeId.empty()) - { - return info; - } - - const RecipeDef* recipe = - getContext().config->recipes.findRecipeDef(m_lastRecipeId, target.type); - if (!recipe) - { - return info; - } - - for (const RecipeIngredient& ingredient : recipe->inputs) - { - info.perCycleInputs[ingredient.item] = ingredient.amount; - } - for (const RecipeOutput& output : recipe->outputs) - { - info.perCycleOutputs[output.item] = output.amount; - } - info.durationSeconds = recipe->durationSeconds; - return info; -} diff --git a/src/ui/selection/AutoProductionContent.h b/src/ui/selection/AutoProductionContent.h deleted file mode 100644 index 63e8183..0000000 --- a/src/ui/selection/AutoProductionContent.h +++ /dev/null @@ -1,29 +0,0 @@ -#pragma once - -#include - -#include "BufferedBuildingContent.h" -#include "SelectionContentFactory.h" - -// The card for a Smelter or a Reprocessing Plant (REQ-UI-SELECTION-CONTENT). Both -// auto-process whatever they receive and have no player-facing recipe selection -// (REQ-BLD-SMELTER, REQ-BLD-REPROCESSING), so the card has no configuration group at -// all; its cycle is whichever recipe is in production, or the last one that was. -class AutoProductionContent : public BufferedBuildingContent -{ - Q_OBJECT - -public: - AutoProductionContent(const SelectionContext& context, - const SelectionRequest& request, QWidget* parent = nullptr); - -protected: - CycleInfo getCycleInfo(const BuildingTarget& target) const override; - -private: - // The recipe last seen in production, which keeps describing the cycle while the - // building sits between cycles (REQ-UI-RECIPE-SUMMARY). Mutable because it is a - // record of what getCycleInfo() has observed rather than state of its own: the card - // shows the same thing whether or not it has been asked before. - mutable std::string m_lastRecipeId; -}; diff --git a/src/ui/selection/CMakeLists.txt b/src/ui/selection/CMakeLists.txt index 75ab62b..a9e93f8 100644 --- a/src/ui/selection/CMakeLists.txt +++ b/src/ui/selection/CMakeLists.txt @@ -20,7 +20,6 @@ SET(HDRS ${CMAKE_CURRENT_SOURCE_DIR}/ClearBeltControl.h ${CMAKE_CURRENT_SOURCE_DIR}/BufferedBuildingContent.h ${CMAKE_CURRENT_SOURCE_DIR}/RecipeProductionContent.h - ${CMAKE_CURRENT_SOURCE_DIR}/AutoProductionContent.h ${CMAKE_CURRENT_SOURCE_DIR}/ShipyardContent.h ${CMAKE_CURRENT_SOURCE_DIR}/StorageContent.h ${CMAKE_CURRENT_SOURCE_DIR}/HqContent.h @@ -55,7 +54,6 @@ SET(SRCS ${CMAKE_CURRENT_SOURCE_DIR}/ClearBeltControl.cpp ${CMAKE_CURRENT_SOURCE_DIR}/BufferedBuildingContent.cpp ${CMAKE_CURRENT_SOURCE_DIR}/RecipeProductionContent.cpp - ${CMAKE_CURRENT_SOURCE_DIR}/AutoProductionContent.cpp ${CMAKE_CURRENT_SOURCE_DIR}/ShipyardContent.cpp ${CMAKE_CURRENT_SOURCE_DIR}/StorageContent.cpp ${CMAKE_CURRENT_SOURCE_DIR}/HqContent.cpp diff --git a/src/ui/selection/SelectionContentFactory.cpp b/src/ui/selection/SelectionContentFactory.cpp index 1ad2f43..79a85a6 100644 --- a/src/ui/selection/SelectionContentFactory.cpp +++ b/src/ui/selection/SelectionContentFactory.cpp @@ -1,6 +1,5 @@ #include "SelectionContentFactory.h" -#include "AutoProductionContent.h" #include "BeltContent.h" #include "DebrisContent.h" #include "FactoryQueries.h" @@ -27,10 +26,9 @@ SelectionContentKind getKindForType(BuildingType type) { case BuildingType::Miner: case BuildingType::Assembler: - return SelectionContentKind::RecipeProduction; case BuildingType::Smelter: case BuildingType::ReprocessingPlant: - return SelectionContentKind::AutoProduction; + return SelectionContentKind::RecipeProduction; case BuildingType::Shipyard: return SelectionContentKind::Shipyard; case BuildingType::SalvageBay: @@ -160,8 +158,6 @@ SelectionContent* createContent(const ContentKey& key, const SelectionRequest& r return nullptr; case SelectionContentKind::RecipeProduction: return new RecipeProductionContent(context, request, parent); - case SelectionContentKind::AutoProduction: - return new AutoProductionContent(context, request, parent); case SelectionContentKind::Shipyard: return new ShipyardContent(context, request, parent); case SelectionContentKind::Storage: diff --git a/src/ui/selection/SelectionContentFactory.h b/src/ui/selection/SelectionContentFactory.h index 7c2d074..44ba1e6 100644 --- a/src/ui/selection/SelectionContentFactory.h +++ b/src/ui/selection/SelectionContentFactory.h @@ -31,8 +31,7 @@ struct SelectionRequest enum class SelectionContentKind { None, // nothing selected: the panel hides itself entirely - RecipeProduction, // Miner, Assembler - AutoProduction, // Smelter, Reprocessing Plant + RecipeProduction, // Miner, Assembler, Smelter, Reprocessing Plant Shipyard, Storage, // Salvage Bay Hq,