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