Auto-process smelter and reprocessing plant (no recipe selection)

This commit is contained in:
2026-07-12 21:35:23 +02:00
parent ad3e73fdd8
commit dd7c997816
4 changed files with 354 additions and 92 deletions

View File

@@ -1,5 +1,6 @@
#include "BuildingSystem.h" #include "BuildingSystem.h"
#include <algorithm>
#include <cassert> #include <cassert>
#include <limits> #include <limits>
#include <random> #include <random>
@@ -9,6 +10,18 @@
#include "SurfaceMask.h" #include "SurfaceMask.h"
#include "tracing.h" #include "tracing.h"
namespace
{
// Smelter and Reprocessing Plant have no player-selected recipe
// (REQ-BLD-SMELTER, REQ-BLD-REPROCESSING). They auto-process whatever inputs
// they receive, matching against every recipe of their building type.
bool isAutoRecipeBuildingType(BuildingType type)
{
return type == BuildingType::Smelter
|| type == BuildingType::ReprocessingPlant;
}
} // namespace
BuildingSystem::BuildingSystem(const GameConfig& config, BuildingSystem::BuildingSystem(const GameConfig& config,
BeltSystem& belts, BeltSystem& belts,
std::function<BuildingId()> allocateBuildingId, std::function<BuildingId()> allocateBuildingId,
@@ -118,6 +131,56 @@ void BuildingSystem::initBuffers(Building& b, const RecipeDef& recipe) const
} }
} }
void BuildingSystem::initAutoBuffers(Building& b) const
{
b.inputBuffer.counts.clear();
b.inputBuffer.caps.clear();
// Union the inputs of every recipe of this building type; the cap for each
// item is twice the largest per-cycle requirement across those recipes.
// Output capacity follows the same rules as initBuffers: the Reprocessing
// Plant holds one cycle's max output (REQ-MAT-OUTPUT-BUFFER-REPROCESSING),
// other auto buildings hold twice the largest per-cycle output.
int outputCapacity = 0;
for (const RecipeDef& recipe : m_config.recipes.recipes)
{
if (recipe.building != b.type)
{
continue;
}
for (const RecipeIngredient& ing : recipe.inputs)
{
const ItemType type{ing.item};
b.inputBuffer.counts[type] = 0;
b.inputBuffer.caps[type] =
std::max(b.inputBuffer.caps[type], 2 * ing.amount);
}
if (b.type == BuildingType::ReprocessingPlant)
{
int maxAmount = 0;
for (const RecipeOutput& out : recipe.outputs)
{
maxAmount = std::max(maxAmount, out.amount);
}
outputCapacity = std::max(outputCapacity, maxAmount);
}
else
{
int totalAmount = 0;
for (const RecipeOutput& out : recipe.outputs)
{
totalAmount += out.amount;
}
outputCapacity = std::max(outputCapacity, 2 * totalAmount);
}
}
b.outputBuffer.items.clear();
b.outputBuffer.capacity = outputCapacity;
}
void BuildingSystem::initShipyardBuffers(Building& b) const void BuildingSystem::initShipyardBuffers(Building& b) const
{ {
b.inputBuffer.counts.clear(); b.inputBuffer.counts.clear();
@@ -419,6 +482,12 @@ void BuildingSystem::setRecipe(BuildingId id, const std::string& recipeId)
{ {
if (site.id == id) 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 // No-op if the recipe is unchanged, so a redundant selection does
// not wipe an already-configured ship layout. // not wipe an already-configured ship layout.
if (site.recipeId == recipeId) if (site.recipeId == recipeId)
@@ -436,6 +505,12 @@ void BuildingSystem::setRecipe(BuildingId id, const std::string& recipeId)
{ {
if (building.id == id) 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 // No-op if the recipe is unchanged, so a redundant selection does
// not wipe an already-configured ship layout or reset buffers. // not wipe an already-configured ship layout or reset buffers.
if (building.recipeId == recipeId) if (building.recipeId == recipeId)
@@ -603,6 +678,12 @@ void BuildingSystem::tickConstruction(Tick currentTick)
{ {
initSalvageBayBuffer(building); initSalvageBayBuffer(building);
} }
else if (isAutoRecipeBuildingType(building.type))
{
// Smelter/Reprocessing Plant need no recipe selection; buffers are set
// up from all recipes of the type (REQ-BLD-SMELTER, REQ-BLD-REPROCESSING).
initAutoBuffers(building);
}
else if (!building.recipeId.empty()) else if (!building.recipeId.empty())
{ {
if (building.type == BuildingType::Shipyard) if (building.type == BuildingType::Shipyard)
@@ -684,18 +765,24 @@ void BuildingSystem::tickBeltPull()
continue; continue;
} }
if (building.recipeId.empty()) // Auto-recipe buildings (Smelter, Reprocessing Plant) accept any item
// that is an input to one of their recipes; their caps already span the
// union of those inputs (initAutoBuffers), so no recipe lookup is needed.
if (!isAutoRecipeBuildingType(building.type))
{ {
continue; if (building.recipeId.empty())
}
if (building.type != BuildingType::Shipyard)
{
const RecipeDef* recipe = findRecipe(building.recipeId, building.type);
if (!recipe || recipe->inputs.empty())
{ {
continue; continue;
} }
if (building.type != BuildingType::Shipyard)
{
const RecipeDef* recipe = findRecipe(building.recipeId, building.type);
if (!recipe || recipe->inputs.empty())
{
continue;
}
}
} }
for (const Port& port : building.inputPorts) for (const Port& port : building.inputPorts)
@@ -752,18 +839,15 @@ void BuildingSystem::tickProduction(Tick currentTick)
continue; continue;
} }
if (building.recipeId.empty()) const bool autoRecipe = isAutoRecipeBuildingType(building.type);
if (!autoRecipe && building.recipeId.empty())
{ {
continue; continue;
} }
const RecipeDef* recipe = findRecipe(building.recipeId, building.type); // If a production cycle is active, check for completion. Completion only
if (!recipe) // needs the already-decided outputs, so it does not depend on which
{ // recipe is selected or auto-chosen.
continue;
}
// If a production cycle is active, check for completion.
if (building.production) if (building.production)
{ {
if (currentTick >= building.production->completesAt) if (currentTick >= building.production->completesAt)
@@ -779,66 +863,93 @@ void BuildingSystem::tickProduction(Tick currentTick)
continue; continue;
} }
// Idle: check if a new cycle can start. // Idle: gather the candidate recipes to try. Auto-recipe buildings
// (Smelter, Reprocessing Plant) have no selected recipe and try every
// 1. All required inputs present? // recipe of their type in config order, running the first whose inputs
bool inputsOk = true; // are satisfied (REQ-BLD-SMELTER, REQ-BLD-REPROCESSING). Other buildings
for (const RecipeIngredient& ing : recipe->inputs) // try only their selected recipe.
std::vector<const RecipeDef*> candidates;
if (autoRecipe)
{ {
const ItemType type{ing.item}; for (const RecipeDef& r : m_config.recipes.recipes)
const std::map<ItemType, int>::const_iterator it =
building.inputBuffer.counts.find(type);
const int have = (it != building.inputBuffer.counts.end()) ? it->second : 0;
if (have < ing.amount)
{ {
inputsOk = false; if (r.building == building.type && !r.inputs.empty())
break;
}
}
if (!inputsOk)
{
continue;
}
// 2. 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)
{
Item item;
item.type.id = out.item;
for (int i = 0; i < out.amount; ++i)
{ {
chosen.push_back(item); candidates.push_back(&r);
} }
} }
} }
else
// 3. Output buffer has space for chosen outputs?
const int newSize = static_cast<int>(building.outputBuffer.items.size())
+ static_cast<int>(chosen.size());
if (newSize > building.outputBuffer.capacity)
{ {
continue; const RecipeDef* recipe = findRecipe(building.recipeId, building.type);
if (recipe)
{
candidates.push_back(recipe);
}
} }
// 4. Consume inputs and start cycle. for (const RecipeDef* recipe : candidates)
for (const RecipeIngredient& ing : recipe->inputs)
{ {
building.inputBuffer.counts[ItemType{ing.item}] -= ing.amount; // 1. All required inputs present?
} bool inputsOk = true;
for (const RecipeIngredient& ing : recipe->inputs)
{
const ItemType type{ing.item};
const std::map<ItemType, int>::const_iterator it =
building.inputBuffer.counts.find(type);
const int have = (it != building.inputBuffer.counts.end()) ? it->second : 0;
if (have < ing.amount)
{
inputsOk = false;
break;
}
}
if (!inputsOk)
{
continue;
}
Production prod; // 2. Determine chosen outputs (roll for reprocessing).
prod.recipeId = building.recipeId; std::vector<Item> chosen;
prod.completesAt = currentTick + secondsToTicks(recipe->durationSeconds); if (building.type == BuildingType::ReprocessingPlant)
prod.chosenOutputs = std::move(chosen); {
building.production = std::move(prod); chosen = rollReprocessingOutput(*recipe);
if (chosen.empty()) { continue; }
}
else
{
for (const RecipeOutput& out : recipe->outputs)
{
Item item;
item.type.id = out.item;
for (int i = 0; i < out.amount; ++i)
{
chosen.push_back(item);
}
}
}
// 3. Output buffer has space for chosen outputs?
const int newSize = static_cast<int>(building.outputBuffer.items.size())
+ static_cast<int>(chosen.size());
if (newSize > building.outputBuffer.capacity)
{
continue;
}
// 4. Consume inputs and start cycle.
for (const RecipeIngredient& ing : recipe->inputs)
{
building.inputBuffer.counts[ItemType{ing.item}] -= ing.amount;
}
Production prod;
prod.recipeId = recipe->id;
prod.completesAt = currentTick + secondsToTicks(recipe->durationSeconds);
prod.chosenOutputs = std::move(chosen);
building.production = std::move(prod);
break; // At most one cycle starts per tick.
}
} }
} }

View File

@@ -169,6 +169,10 @@ private:
const ShipDef* findShipDef(const std::string& id) const; const ShipDef* findShipDef(const std::string& id) const;
const ModuleDef* findModuleDef(const std::string& id) const; const ModuleDef* findModuleDef(const std::string& id) const;
void initBuffers(Building& b, const RecipeDef& recipe) const; void initBuffers(Building& b, const RecipeDef& recipe) const;
// Buffers for an auto-recipe building (Smelter, Reprocessing Plant): input
// caps span the union of every recipe of the building's type; no player
// recipe is selected (REQ-BLD-SMELTER, REQ-BLD-REPROCESSING).
void initAutoBuffers(Building& b) const;
void initShipyardBuffers(Building& b) const; void initShipyardBuffers(Building& b) const;
void initSalvageBayBuffer(Building& b) const; void initSalvageBayBuffer(Building& b) const;
std::vector<Port> computeInputPorts(const Building& b) const; std::vector<Port> computeInputPorts(const Building& b) const;

View File

@@ -474,7 +474,8 @@ TEST_CASE("BuildingSystem: productionBuildingCount excludes construction sites",
runTicks(bs, belts, static_cast<int>(secondsToTicks(15.0)), tick); runTicks(bs, belts, static_cast<int>(secondsToTicks(15.0)), tick);
REQUIRE(bs.productionBuildingCount() == 2); REQUIRE(bs.productionBuildingCount() == 2);
// Neither has a recipe selected, so neither has an active cycle. // Neither is producing yet: the miner has no recipe selected, and the
// smelter (auto-recipe, REQ-BLD-SMELTER) has no input feeding it.
REQUIRE(bs.activeProductionBuildingCount() == 0); REQUIRE(bs.activeProductionBuildingCount() == 0);
bs.setRecipe(minerId, "mine_iron_ore"); bs.setRecipe(minerId, "mine_iron_ore");
@@ -542,7 +543,8 @@ TEST_CASE("BuildingSystem: smelter input buffer fills from adjacent west-flowing
// Smelter mask ["AA ","AA>"] → body (0,0),(1,0),(0,1),(1,1). // Smelter mask ["AA ","AA>"] → body (0,0),(1,0),(0,1),(1,1).
// Output port (2,1) East. Input port example: (2,0) West. // Output port (2,1) East. Input port example: (2,0) West.
const BuildingId sid = bs.place(BuildingType::Smelter, QPoint(0, 0), Rotation::East, 0); const BuildingId sid = bs.place(BuildingType::Smelter, QPoint(0, 0), Rotation::East, 0);
bs.setRecipe(sid, "iron_ingot"); // Smelters have no recipe selection (REQ-BLD-SMELTER); they auto-accept any
// ore/scrap that is an input to a smelter recipe.
// Complete construction (15s → tick 450+1 = 451 ticks). // Complete construction (15s → tick 450+1 = 451 ticks).
Tick tick = 0; Tick tick = 0;
@@ -563,6 +565,105 @@ TEST_CASE("BuildingSystem: smelter input buffer fills from adjacent west-flowing
REQUIRE(it->second >= 1); REQUIRE(it->second >= 1);
} }
// A smelter auto-selects the matching recipe for whatever it is fed, with no
// player recipe selection (REQ-BLD-SMELTER).
TEST_CASE("BuildingSystem: smelter auto-smelts ore without a recipe selection",
"[building]")
{
const GameConfig cfg = loadConfig();
BeltSystem belts(static_cast<double>(kTickRateHz));
int stock = 0;
std::mt19937 rng(0);
BuildingId nextBuildingId = 1;
BuildingSystem bs(cfg, belts,
[&nextBuildingId]() { return nextBuildingId++; },
[&stock](int n) { stock += n; },
[](const std::string&, QVector2D, const std::optional<ShipLayoutConfig>&) {},
[](const std::string&) -> bool { return true; },
rng);
const BuildingId sid = bs.place(BuildingType::Smelter, QPoint(0, 0), Rotation::East, 0);
Tick tick = 0;
runTicks(bs, belts, static_cast<int>(secondsToTicks(15.0)) + 1, tick);
// Feed 2 iron_ore (the test-config iron_ingot recipe needs 2) via a
// west-flowing belt at input port (2,0).
belts.placeBelt(QPoint(2, 0), Rotation::West);
for (int i = 0; i < 2; ++i)
{
belts.tryPutItem(QPoint(2, 0), makeItem("iron_ore"));
belts.tick();
bs.tickBeltPull();
}
// iron_ingot recipe cycle is 2s; run to completion.
runTicks(bs, belts, static_cast<int>(secondsToTicks(2.0)) + 2, tick);
const Building* b = bs.findBuilding(sid);
REQUIRE(b != nullptr);
bool hasIronIngot = false;
for (const Item& item : b->outputBuffer.items)
{
if (item.type.id == "iron_ingot") { hasIronIngot = true; }
}
REQUIRE(hasIronIngot);
}
// With mixed inputs, the smelter runs whichever recipe is currently satisfiable
// and leaves an incomplete batch of another input waiting (see the union-of-
// inputs caps in initAutoBuffers).
TEST_CASE("BuildingSystem: smelter runs a satisfiable recipe while an incomplete batch waits",
"[building]")
{
const GameConfig cfg = loadConfig();
BeltSystem belts(static_cast<double>(kTickRateHz));
int stock = 0;
std::mt19937 rng(0);
BuildingId nextBuildingId = 1;
BuildingSystem bs(cfg, belts,
[&nextBuildingId]() { return nextBuildingId++; },
[&stock](int n) { stock += n; },
[](const std::string&, QVector2D, const std::optional<ShipLayoutConfig>&) {},
[](const std::string&) -> bool { return true; },
rng);
const BuildingId sid = bs.place(BuildingType::Smelter, QPoint(0, 0), Rotation::East, 0);
Tick tick = 0;
runTicks(bs, belts, static_cast<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.
belts.placeBelt(QPoint(2, 0), Rotation::West);
const char* fed[] = { "iron_ore", "copper_ore", "copper_ore" };
for (const char* id : fed)
{
belts.tryPutItem(QPoint(2, 0), makeItem(id));
belts.tick();
bs.tickBeltPull();
}
// copper_ingot cycle is 2.5s; run to completion.
runTicks(bs, belts, static_cast<int>(secondsToTicks(2.5)) + 2, tick);
const Building* b = bs.findBuilding(sid);
REQUIRE(b != nullptr);
// Copper was smelted; the lone iron_ore still waits for a second unit.
bool hasCopperIngot = false;
for (const Item& item : b->outputBuffer.items)
{
if (item.type.id == "copper_ingot") { hasCopperIngot = true; }
}
REQUIRE(hasCopperIngot);
const std::map<ItemType, int>::const_iterator ironIt =
b->inputBuffer.counts.find(ItemType{"iron_ore"});
REQUIRE(ironIt != b->inputBuffer.counts.end());
REQUIRE(ironIt->second == 1);
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Belt push → belt tile // Belt push → belt tile
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -664,7 +765,8 @@ TEST_CASE("BuildingSystem: reprocessing plant output buffer capacity equals max
const BuildingId id = bs.place(BuildingType::ReprocessingPlant, const BuildingId id = bs.place(BuildingType::ReprocessingPlant,
QPoint(0, 0), Rotation::East, 0); QPoint(0, 0), Rotation::East, 0);
bs.setRecipe(id, "reprocessing_cycle"); // Reprocessing plants have no recipe selection (REQ-BLD-REPROCESSING); the
// single reprocessing recipe is applied automatically on completion.
// Complete construction (25s). // Complete construction (25s).
Tick tick = 0; Tick tick = 0;
@@ -695,7 +797,8 @@ TEST_CASE("BuildingSystem: reprocessing plant produces one cycle output then sta
const BuildingId id = bs.place(BuildingType::ReprocessingPlant, const BuildingId id = bs.place(BuildingType::ReprocessingPlant,
QPoint(0, 0), Rotation::East, 0); QPoint(0, 0), Rotation::East, 0);
bs.setRecipe(id, "reprocessing_cycle"); // Reprocessing plants have no recipe selection (REQ-BLD-REPROCESSING); the
// single reprocessing recipe is applied automatically on completion.
// Complete construction (25s). // Complete construction (25s).
Tick tick = 0; Tick tick = 0;

View File

@@ -78,6 +78,25 @@ bool isProductionBuilding(BuildingType type)
|| type == BuildingType::Shipyard; || type == BuildingType::Shipyard;
} }
// Buildings that expose a player recipe/schematic selection control
// (REQ-UI-SELECT-BUTTON): Miner ore type, Assembler recipe, Shipyard schematic.
// The Smelter and Reprocessing Plant auto-process and offer no selection
// (REQ-BLD-SMELTER, REQ-BLD-REPROCESSING).
bool hasRecipeSelection(BuildingType type)
{
return type == BuildingType::Miner
|| type == BuildingType::Assembler
|| type == BuildingType::Shipyard;
}
// Auto-recipe buildings have no selected recipe; their production is driven by
// whatever inputs they receive.
bool isAutoRecipeBuilding(BuildingType type)
{
return type == BuildingType::Smelter
|| type == BuildingType::ReprocessingPlant;
}
bool isBeltLike(BuildingType type) bool isBeltLike(BuildingType type)
{ {
return type == BuildingType::Belt || type == BuildingType::Splitter return type == BuildingType::Belt || type == BuildingType::Splitter
@@ -265,7 +284,7 @@ void SelectedBuildingPanel::buildSingle(BuildingId id)
m_titleLabel->show(); m_titleLabel->show();
m_buffersLabel->show(); m_buffersLabel->show();
if (isProductionBuilding(type)) if (hasRecipeSelection(type))
{ {
const std::vector<RecipeSelectionOption> options = const std::vector<RecipeSelectionOption> options =
buildRecipeSelectionOptions(type, *m_sim, *m_config); buildRecipeSelectionOptions(type, *m_sim, *m_config);
@@ -383,6 +402,21 @@ void SelectedBuildingPanel::refreshBuffers(const Building* b)
? findShipDef(b->recipeId) ? findShipDef(b->recipeId)
: nullptr; : nullptr;
// Auto-recipe buildings (Smelter, Reprocessing Plant) have no selected
// recipe; while a cycle runs, resolve the recipe actually in production so
// the cycle time and progress can be shown (REQ-UI-PRODUCTION-PROGRESS).
if (!recipe && isAutoRecipeBuilding(b->type) && b->production.has_value())
{
for (const RecipeDef& r : m_config->recipes.recipes)
{
if (r.id == b->production->recipeId && r.building == b->type)
{
recipe = &r;
break;
}
}
}
QString bufText; QString bufText;
if (!b->inputBuffer.counts.empty()) if (!b->inputBuffer.counts.empty())
@@ -469,41 +503,51 @@ void SelectedBuildingPanel::refreshBuffers(const Building* b)
} }
} }
if (isProductionBuilding(b->type) && (recipe || shipDef)) if (isProductionBuilding(b->type)
&& (recipe || shipDef || isAutoRecipeBuilding(b->type)))
{ {
double durationSeconds = recipe if (recipe || shipDef)
? recipe->durationSeconds
: shipDef->schematic.productionTimeSeconds;
if (shipDef && b->shipLayout.has_value())
{ {
for (const PlacedModule& pm : b->shipLayout->placedModules) double durationSeconds = recipe
? recipe->durationSeconds
: shipDef->schematic.productionTimeSeconds;
if (shipDef && b->shipLayout.has_value())
{ {
for (const ModuleDef& modDef : m_config->modules.modules) for (const PlacedModule& pm : b->shipLayout->placedModules)
{ {
if (modDef.id == pm.moduleId) for (const ModuleDef& modDef : m_config->modules.modules)
{ {
durationSeconds += modDef.productionTimeSeconds; if (modDef.id == pm.moduleId)
break; {
durationSeconds += modDef.productionTimeSeconds;
break;
}
} }
} }
} }
}
bufText += tr("Cycle: %1 s\n").arg(durationSeconds, 0, 'f', 1); bufText += tr("Cycle: %1 s\n").arg(durationSeconds, 0, 'f', 1);
if (b->production.has_value()) if (b->production.has_value())
{ {
const Tick cycleTicks = secondsToTicks(durationSeconds); const Tick cycleTicks = secondsToTicks(durationSeconds);
const Tick completesAt = b->production->completesAt; const Tick completesAt = b->production->completesAt;
const Tick currentTick = m_sim->currentTick(); const Tick currentTick = m_sim->currentTick();
const Tick elapsed = currentTick - (completesAt - cycleTicks); const Tick elapsed = currentTick - (completesAt - cycleTicks);
const int pct = static_cast<int>( const int pct = static_cast<int>(
std::max(Tick(0), std::min(cycleTicks, elapsed)) * 100 / cycleTicks); std::max(Tick(0), std::min(cycleTicks, elapsed)) * 100 / cycleTicks);
bufText += tr("Progress: %1%\n").arg(pct); bufText += tr("Progress: %1%\n").arg(pct);
}
else
{
bufText += tr("Progress: idle\n");
}
} }
else else
{ {
// Auto-recipe building with no active cycle: no single recipe to
// show a cycle time for.
bufText += tr("Progress: idle\n"); bufText += tr("Progress: idle\n");
} }
} }