make recipe selection and buffers of smelter and reprocessing plant behave like other buildings, except that a recipe may be chosen automatically

This commit is contained in:
2026-08-12 23:24:31 +02:00
parent 4ee6438405
commit 9bbade2420
18 changed files with 363 additions and 335 deletions

View File

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

View File

@@ -30,9 +30,9 @@ std::optional<BuildingType> 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);

View File

@@ -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();

View File

@@ -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);

View File

@@ -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<ItemType> 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<Item> 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<const RecipeDef*> 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<Item> 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<Item> 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);
}
}

View File

@@ -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).

View File

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

View File

@@ -8,29 +8,37 @@
#include "ModulesConfig.h"
#include "ShipsConfig.h"
std::vector<const RecipeDef*>
gatherCandidateRecipes(const GameConfig& config, const Building& b)
const RecipeDef* getSelectedRecipe(const GameConfig& config, const Building& b)
{
std::vector<const RecipeDef*> 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<ProductionStatus>
@@ -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;
}

View File

@@ -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<const RecipeDef*> 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);

View File

@@ -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<int>(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<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.
// 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<int>(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<ItemType, int>::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<int>(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<int>(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")

View File

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

View File

@@ -50,7 +50,13 @@ std::vector<RecipeSelectionOption> buildRecipeSelectionOptions(
BuildingType type, Simulation& sim, const GameConfig& config)
{
std::vector<RecipeSelectionOption> 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)
{

View File

@@ -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;
}

View File

@@ -1,29 +0,0 @@
#pragma once
#include <string>
#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;
};

View File

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

View File

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

View File

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