stop a building drifting into a recipe the player could not pick

An auto-recipe building adopted whatever recipe of its type consumed the
material offered to it, without asking the unlock state. That was the last way
a building could come to run a recipe the player could not have selected: the
selection dialog hides those and the blueprint gate discards them, but a belt
delivering the right material installed one regardless.

Automatic selection now asks isRecipeUnlocked, the same question the dialog
asks, plumbed in beside the isItemUnlocked the output pool already uses. Two
callbacks, two questions, one unlock state -- rather than a second definition of
"a recipe the player may run" written out inside the sim.

A material whose only recipes are locked now selects nothing, and an
unconfigured building has no input buffer, so that material is refused rather
than swallowed: it stays on the belt and the line backs up behind an idle
building. That is the intended failure -- a stalled belt is visible, a building
quietly eating a material the player cannot use is not.

Selection only. A recipe already set goes on producing under
REQ-LOCK-OUTPUT-POOL, which deliberately never tests a single-group recipe.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ne3mejABZoLWKLh8fgpM3x
This commit is contained in:
2026-08-20 22:13:18 +02:00
parent d2b1363f6d
commit 20789cb15c
5 changed files with 80 additions and 10 deletions

View File

@@ -16,10 +16,12 @@ ProductionSystem::ProductionSystem(const GameConfig& config,
std::function<void(const std::string&, QVector2D,
const std::optional<ShipLayoutConfig>&)> spawnShip,
std::function<bool(const std::string&)> isItemUnlocked,
std::function<bool(const std::string&)> isRecipeUnlocked,
std::mt19937& rng)
: m_config(config)
, m_spawnShip(std::move(spawnShip))
, m_isItemUnlocked(std::move(isItemUnlocked))
, m_isRecipeUnlocked(std::move(isRecipeUnlocked))
, m_rng(rng)
{
}
@@ -157,6 +159,15 @@ void ProductionSystem::selectAutoRecipeIfUnset(Building& building, const ItemTyp
{
return;
}
// Only a recipe the player could have selected themselves (REQ-LOCK-UI-RECIPE): a
// building must not drift into running one that can yield them nothing. Refusing it
// leaves the building unconfigured and so without an input buffer, which is what
// keeps the offered item on the belt rather than swallowing it into a building that
// has no use for it (REQ-MAT-INPUT-INTAKE).
if (!m_isRecipeUnlocked(recipe->id))
{
return;
}
building.recipeId = recipe->id;
initBuffers(building, *recipe);

View File

@@ -44,10 +44,14 @@ class BeltSystem;
class ProductionSystem
{
public:
// isItemUnlocked decides which output groups a cycle may pick (REQ-LOCK-OUTPUT-POOL);
// isRecipeUnlocked decides which recipe an auto-recipe building may adopt
// (REQ-BLD-AUTO-RECIPE). Two questions, asked of the same unlock state.
ProductionSystem(const GameConfig& config,
std::function<void(const std::string&, QVector2D,
const std::optional<ShipLayoutConfig>&)> spawnShip,
std::function<bool(const std::string&)> isItemUnlocked,
std::function<bool(const std::string&)> isRecipeUnlocked,
std::mt19937& rng);
// Advances every building's virtual input belts, delivers what arrives into the input
@@ -93,12 +97,13 @@ private:
std::vector<Item> rollOutputGroup(const RecipeDef& recipe);
const GameConfig& m_config;
// Spawning a finished ship reaches into the entity model, and an output group's
// eligibility into the unlock state; neither is factory data, so both arrive as
// Spawning a finished ship reaches into the entity model, and the two unlock
// questions into the unlock state; neither is factory data, so all three arrive as
// callbacks rather than living in FactoryState.
std::function<void(const std::string&, QVector2D,
const std::optional<ShipLayoutConfig>&)> m_spawnShip;
std::function<bool(const std::string&)> m_isItemUnlocked;
std::function<bool(const std::string&)> m_isRecipeUnlocked;
// The simulation's one RNG, by reference: a draw here shares the stream with every
// other draw in the run, which is what makes a replay reproducible (docs/replay_design.md).
std::mt19937& m_rng;

View File

@@ -120,6 +120,7 @@ void Simulation::initializeSubsystems()
m_shipSystem->spawn(id, pos, /*isEnemy=*/false, layout);
},
[this](const std::string& itemId) -> bool { return isItemUnlocked(itemId); },
[this](const std::string& recipeId) -> bool { return isRecipeUnlocked(recipeId); },
m_rng);
m_constructionSystem = std::make_unique<ConstructionSystem>(m_config);
m_deconstructionSystem = std::make_unique<DeconstructionSystem>(m_config);