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

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