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:
@@ -154,7 +154,8 @@ Any ship, module, building, or assembler recipe id that appears in no unlock gro
|
|||||||
- REQ-BLD-ASSEMBLER: **Assembler** (3×3): The player selects a recipe from the config-defined crafting tree. Produces what that recipe produces (REQ-MAT-OUTPUT-GROUP) at the rate defined in the corresponding `recipes.toml [[recipe]]` entry with `building = "assembler"`. Only implicitly unlocked recipes are available for selection (REQ-LOCK-UI-RECIPE).
|
- REQ-BLD-ASSEMBLER: **Assembler** (3×3): The player selects a recipe from the config-defined crafting tree. Produces what that recipe produces (REQ-MAT-OUTPUT-GROUP) at the rate defined in the corresponding `recipes.toml [[recipe]]` entry with `building = "assembler"`. Only implicitly unlocked recipes are available for selection (REQ-LOCK-UI-RECIPE).
|
||||||
- REQ-BLD-REPROCESSING: **Reprocessing Plant** (3×3): Consumes scrap and returns a higher-tier material chosen by chance, as the value-preserving counterpart to smelting scrap down (REQ-BLD-SMELTER) and the only source of voidsteel. Its inputs, output groups and weights are ordinary recipe config (REQ-MAT-OUTPUT-GROUP) with `building = "reprocessing_plant"`; nothing about its behaviour is specific to the building. Reprocessing recipes take no part in the item traversal of REQ-LOCK-IMPLICIT (steps 2–3): being obtainable from a plant must never be what makes an item unlocked. They are judged for recipe unlocking like every other recipe (step 4), and what a running plant may yield is governed by REQ-LOCK-OUTPUT-POOL. Both of those read the item set; neither feeds it.
|
- REQ-BLD-REPROCESSING: **Reprocessing Plant** (3×3): Consumes scrap and returns a higher-tier material chosen by chance, as the value-preserving counterpart to smelting scrap down (REQ-BLD-SMELTER) and the only source of voidsteel. Its inputs, output groups and weights are ordinary recipe config (REQ-MAT-OUTPUT-GROUP) with `building = "reprocessing_plant"`; nothing about its behaviour is specific to the building. Reprocessing recipes take no part in the item traversal of REQ-LOCK-IMPLICIT (steps 2–3): being obtainable from a plant must never be what makes an item unlocked. They are judged for recipe unlocking like every other recipe (step 4), and what a running plant may yield is governed by REQ-LOCK-OUTPUT-POOL. Both of those read the item set; neither feeds it.
|
||||||
- REQ-BLD-AUTO-RECIPE: **Automatic recipe selection.** The Smelter and the Reprocessing Plant (REQ-BLD-SMELTER, REQ-BLD-REPROCESSING) are *auto-recipe buildings*. They carry a selected recipe and are configured exactly as a Miner or Assembler is — the same selection button and dialog (REQ-UI-SELECT-BUTTON), buffers sized for that one recipe alone (REQ-MAT-INPUT-BUFFER, REQ-MAT-OUTPUT-BUFFER), and the same pre-configuration on a construction site (REQ-BLD-SITE-CONFIG). They differ in one respect only:
|
- REQ-BLD-AUTO-RECIPE: **Automatic recipe selection.** The Smelter and the Reprocessing Plant (REQ-BLD-SMELTER, REQ-BLD-REPROCESSING) are *auto-recipe buildings*. They carry a selected recipe and are configured exactly as a Miner or Assembler is — the same selection button and dialog (REQ-UI-SELECT-BUTTON), buffers sized for that one recipe alone (REQ-MAT-INPUT-BUFFER, REQ-MAT-OUTPUT-BUFFER), and the same pre-configuration on a construction site (REQ-BLD-SITE-CONFIG). They differ in one respect only:
|
||||||
- **Selection while none is set.** When such a building has no recipe, the first material offered at any of its input ports that some recipe of its type consumes selects that recipe; the material is then accepted as normal. This holds for every intake path — a belt, splitter or tunnel exit at an input port, and a directly coupled producer (REQ-MAT-DIRECT-COUPLE) — because a building that accepts nothing would otherwise leave a coupled producer stuck at its port forever. The choice is deterministic: input ports are examined in order, and where several recipes of the type consume the offered material the first in config order wins.
|
- **Selection while none is set.** When such a building has no recipe, the first material offered at any of its input ports that some **unlocked** recipe of its type consumes selects that recipe; the material is then accepted as normal. This holds for every intake path — a belt, splitter or tunnel exit at an input port, and a directly coupled producer (REQ-MAT-DIRECT-COUPLE) — because a building that accepts nothing would otherwise leave a coupled producer stuck at its port forever. The choice is deterministic: input ports are examined in order, and where several recipes of the type consume the offered material the first in config order wins.
|
||||||
|
- **Only what the player could have chosen.** Automatic selection is restricted to recipes that are currently unlocked (REQ-LOCK-IMPLICIT), the same set the selection dialog offers (REQ-LOCK-UI-RECIPE): a building must not drift into running a recipe the player could not have selected and that can yield them nothing. A material whose only recipes are locked therefore selects nothing, and since an unconfigured building has no input buffer, that material is refused rather than accepted — it stays on the belt and the line backs up behind an idle building. This is deliberate: a player who routes a material they have no use for yet gets a visibly stalled belt rather than a building quietly consuming it. The restriction applies to selection alone; a recipe already set goes on producing under REQ-LOCK-OUTPUT-POOL, which does not test a single-group recipe's output at all.
|
||||||
- **No further switching.** Once a recipe is set the building keeps it. It does not switch when its buffers run empty, nor when a material belonging to another of its recipes arrives — that material is simply not an accepted input, exactly as for any other building.
|
- **No further switching.** Once a recipe is set the building keeps it. It does not switch when its buffers run empty, nor when a material belonging to another of its recipes arrives — that material is simply not an accepted input, exactly as for any other building.
|
||||||
- **The player is always in control.** Selecting a different recipe clears the buffers (REQ-MAT-INPUT-BUFFER, REQ-MAT-OUTPUT-BUFFER); that is how a building left holding part of a cycle nothing feeds any more is freed, and how a selection the player did not want is corrected. For these buildings the dialog's clearing option is captioned `(Auto)` rather than `(None)` (REQ-UI-SELECT-OPTIONS): it unsets the recipe and hands the building back to automatic selection.
|
- **The player is always in control.** Selecting a different recipe clears the buffers (REQ-MAT-INPUT-BUFFER, REQ-MAT-OUTPUT-BUFFER); that is how a building left holding part of a cycle nothing feeds any more is freed, and how a selection the player did not want is corrected. For these buildings the dialog's clearing option is captioned `(Auto)` rather than `(None)` (REQ-UI-SELECT-OPTIONS): it unsets the recipe and hands the building back to automatic selection.
|
||||||
- REQ-BLD-SHIPYARD: **Shipyard** (4×2): The player selects a schematic. When all required materials — the ship's base materials (`[ship.schematic].materials`) plus the materials of all modules in the configured layout (REQ-MOD-MATERIALS) — are present in its input buffer, the shipyard consumes them and begins a production cycle lasting the ship's base `[ship.schematic].production_time_seconds` plus the sum of production times contributed by all module instances in the configured layout (REQ-MOD-PRODUCTION-TIME). One ship of that type is spawned with the configured modules when the cycle completes. The shipyard cannot start a new cycle while one is in progress. If the player confirms a layout change (REQ-MOD-UI-DIALOG) while a production cycle is in progress, the current cycle is cancelled and all consumed materials are discarded; the shipyard returns to idle with the new layout configuration. Confirming a layout identical to the one already configured is not a change and cancels nothing (REQ-MAT-INPUT-BUFFER).
|
- REQ-BLD-SHIPYARD: **Shipyard** (4×2): The player selects a schematic. When all required materials — the ship's base materials (`[ship.schematic].materials`) plus the materials of all modules in the configured layout (REQ-MOD-MATERIALS) — are present in its input buffer, the shipyard consumes them and begins a production cycle lasting the ship's base `[ship.schematic].production_time_seconds` plus the sum of production times contributed by all module instances in the configured layout (REQ-MOD-PRODUCTION-TIME). One ship of that type is spawned with the configured modules when the cycle completes. The shipyard cannot start a new cycle while one is in progress. If the player confirms a layout change (REQ-MOD-UI-DIALOG) while a production cycle is in progress, the current cycle is cancelled and all consumed materials are discarded; the shipyard returns to idle with the new layout configuration. Confirming a layout identical to the one already configured is not a change and cancels nothing (REQ-MAT-INPUT-BUFFER).
|
||||||
|
|||||||
@@ -16,10 +16,12 @@ ProductionSystem::ProductionSystem(const GameConfig& config,
|
|||||||
std::function<void(const std::string&, QVector2D,
|
std::function<void(const std::string&, QVector2D,
|
||||||
const std::optional<ShipLayoutConfig>&)> spawnShip,
|
const std::optional<ShipLayoutConfig>&)> spawnShip,
|
||||||
std::function<bool(const std::string&)> isItemUnlocked,
|
std::function<bool(const std::string&)> isItemUnlocked,
|
||||||
|
std::function<bool(const std::string&)> isRecipeUnlocked,
|
||||||
std::mt19937& rng)
|
std::mt19937& rng)
|
||||||
: m_config(config)
|
: m_config(config)
|
||||||
, m_spawnShip(std::move(spawnShip))
|
, m_spawnShip(std::move(spawnShip))
|
||||||
, m_isItemUnlocked(std::move(isItemUnlocked))
|
, m_isItemUnlocked(std::move(isItemUnlocked))
|
||||||
|
, m_isRecipeUnlocked(std::move(isRecipeUnlocked))
|
||||||
, m_rng(rng)
|
, m_rng(rng)
|
||||||
{
|
{
|
||||||
}
|
}
|
||||||
@@ -157,6 +159,15 @@ void ProductionSystem::selectAutoRecipeIfUnset(Building& building, const ItemTyp
|
|||||||
{
|
{
|
||||||
return;
|
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;
|
building.recipeId = recipe->id;
|
||||||
initBuffers(building, *recipe);
|
initBuffers(building, *recipe);
|
||||||
|
|||||||
@@ -44,10 +44,14 @@ class BeltSystem;
|
|||||||
class ProductionSystem
|
class ProductionSystem
|
||||||
{
|
{
|
||||||
public:
|
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,
|
ProductionSystem(const GameConfig& config,
|
||||||
std::function<void(const std::string&, QVector2D,
|
std::function<void(const std::string&, QVector2D,
|
||||||
const std::optional<ShipLayoutConfig>&)> spawnShip,
|
const std::optional<ShipLayoutConfig>&)> spawnShip,
|
||||||
std::function<bool(const std::string&)> isItemUnlocked,
|
std::function<bool(const std::string&)> isItemUnlocked,
|
||||||
|
std::function<bool(const std::string&)> isRecipeUnlocked,
|
||||||
std::mt19937& rng);
|
std::mt19937& rng);
|
||||||
|
|
||||||
// Advances every building's virtual input belts, delivers what arrives into the input
|
// 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);
|
std::vector<Item> rollOutputGroup(const RecipeDef& recipe);
|
||||||
|
|
||||||
const GameConfig& m_config;
|
const GameConfig& m_config;
|
||||||
// Spawning a finished ship reaches into the entity model, and an output group's
|
// Spawning a finished ship reaches into the entity model, and the two unlock
|
||||||
// eligibility into the unlock state; neither is factory data, so both arrive as
|
// questions into the unlock state; neither is factory data, so all three arrive as
|
||||||
// callbacks rather than living in FactoryState.
|
// callbacks rather than living in FactoryState.
|
||||||
std::function<void(const std::string&, QVector2D,
|
std::function<void(const std::string&, QVector2D,
|
||||||
const std::optional<ShipLayoutConfig>&)> m_spawnShip;
|
const std::optional<ShipLayoutConfig>&)> m_spawnShip;
|
||||||
std::function<bool(const std::string&)> m_isItemUnlocked;
|
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
|
// 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).
|
// other draw in the run, which is what makes a replay reproducible (docs/replay_design.md).
|
||||||
std::mt19937& m_rng;
|
std::mt19937& m_rng;
|
||||||
|
|||||||
@@ -120,6 +120,7 @@ void Simulation::initializeSubsystems()
|
|||||||
m_shipSystem->spawn(id, pos, /*isEnemy=*/false, layout);
|
m_shipSystem->spawn(id, pos, /*isEnemy=*/false, layout);
|
||||||
},
|
},
|
||||||
[this](const std::string& itemId) -> bool { return isItemUnlocked(itemId); },
|
[this](const std::string& itemId) -> bool { return isItemUnlocked(itemId); },
|
||||||
|
[this](const std::string& recipeId) -> bool { return isRecipeUnlocked(recipeId); },
|
||||||
m_rng);
|
m_rng);
|
||||||
m_constructionSystem = std::make_unique<ConstructionSystem>(m_config);
|
m_constructionSystem = std::make_unique<ConstructionSystem>(m_config);
|
||||||
m_deconstructionSystem = std::make_unique<DeconstructionSystem>(m_config);
|
m_deconstructionSystem = std::make_unique<DeconstructionSystem>(m_config);
|
||||||
|
|||||||
@@ -110,20 +110,29 @@ struct PlacementFixture
|
|||||||
return state.buildingBlocksStock - cfg.world.startingBuildingBlocks;
|
return state.buildingBlocksStock - cfg.world.startingBuildingBlocks;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Everything counts as unlocked unless a test says otherwise, which is what the two
|
||||||
|
// unlock-driven rules turn on: output-group eligibility (REQ-LOCK-OUTPUT-POOL) and
|
||||||
|
// automatic recipe selection (REQ-BLD-AUTO-RECIPE).
|
||||||
|
static std::function<bool(const std::string&)> unlockedUnless(
|
||||||
|
std::function<bool(const std::string&)> given)
|
||||||
|
{
|
||||||
|
return given ? std::move(given)
|
||||||
|
: std::function<bool(const std::string&)>(
|
||||||
|
[](const std::string&) { return true; });
|
||||||
|
}
|
||||||
|
|
||||||
// Defaults to the configured belt speed; pass kFastBeltSpeed_tps where the test
|
// Defaults to the configured belt speed; pass kFastBeltSpeed_tps where the test
|
||||||
// needs items to arrive immediately. Everything counts as unlocked unless the test
|
// needs items to arrive immediately.
|
||||||
// says otherwise, which is what the output-group eligibility rule turns on
|
|
||||||
// (REQ-LOCK-OUTPUT-POOL).
|
|
||||||
explicit PlacementFixture(
|
explicit PlacementFixture(
|
||||||
std::optional<double> beltSpeed_tps = std::nullopt,
|
std::optional<double> beltSpeed_tps = std::nullopt,
|
||||||
std::function<bool(const std::string&)> isItemUnlocked = nullptr)
|
std::function<bool(const std::string&)> isItemUnlocked = nullptr,
|
||||||
|
std::function<bool(const std::string&)> isRecipeUnlocked = nullptr)
|
||||||
: belts(beltSpeed_tps.value_or(cfg.world.beltSpeed_tps))
|
: belts(beltSpeed_tps.value_or(cfg.world.beltSpeed_tps))
|
||||||
, bs(cfg)
|
, bs(cfg)
|
||||||
, production(cfg,
|
, production(cfg,
|
||||||
[](const std::string&, QVector2D, const std::optional<ShipLayoutConfig>&) {},
|
[](const std::string&, QVector2D, const std::optional<ShipLayoutConfig>&) {},
|
||||||
isItemUnlocked ? std::move(isItemUnlocked)
|
unlockedUnless(std::move(isItemUnlocked)),
|
||||||
: std::function<bool(const std::string&)>(
|
unlockedUnless(std::move(isRecipeUnlocked)),
|
||||||
[](const std::string&) { return true; }),
|
|
||||||
rng)
|
rng)
|
||||||
{
|
{
|
||||||
}
|
}
|
||||||
@@ -1303,6 +1312,49 @@ TEST_CASE("BuildingSystem: a set recipe is never replaced by a later material",
|
|||||||
REQUIRE(f.belts.peekItem(westPort(QPoint(2, 0))).has_value());
|
REQUIRE(f.belts.peekItem(westPort(QPoint(2, 0))).has_value());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
TEST_CASE("BuildingSystem: a locked recipe is not auto-selected", "[building][unlock]")
|
||||||
|
{
|
||||||
|
// Auto-selection may only reach for a recipe the player could have selected
|
||||||
|
// themselves (REQ-BLD-AUTO-RECIPE, REQ-LOCK-UI-RECIPE) -- a building must not drift
|
||||||
|
// into running one that can yield them nothing. Here the iron recipe is locked, so
|
||||||
|
// the ore that would normally select it selects nothing.
|
||||||
|
PlacementFixture f(kFastBeltSpeed_tps, nullptr,
|
||||||
|
[](const std::string& id) { return id != "iron_ingot"; });
|
||||||
|
Tick tick = 0;
|
||||||
|
const BuildingId id = buildSmelter(f, QPoint(0, 0), tick);
|
||||||
|
|
||||||
|
f.belts.placeBelt(QPoint(2, 0), Rotation::West);
|
||||||
|
f.belts.tryPutItem(QPoint(2, 0), makeItem("iron_ore"));
|
||||||
|
f.belts.tick();
|
||||||
|
f.production.tickBeltPull(f.state, f.belts);
|
||||||
|
|
||||||
|
const Building* b = findBuilding(f.state, id);
|
||||||
|
REQUIRE(b->recipeId.empty());
|
||||||
|
// Unconfigured means no input buffer, so the ore is refused rather than swallowed
|
||||||
|
// into a building that has no use for it: it is still on the belt.
|
||||||
|
REQUIRE(b->inputBuffer.caps.empty());
|
||||||
|
REQUIRE(b->pendingInputCount(ItemType{"iron_ore"}) == 0);
|
||||||
|
REQUIRE(f.belts.peekItem(westPort(QPoint(2, 0))).has_value());
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("BuildingSystem: an unlocked recipe is still auto-selected", "[building][unlock]")
|
||||||
|
{
|
||||||
|
// The counterpart to the case above: with the iron recipe locked, copper ore still
|
||||||
|
// selects the copper recipe, so the gate refuses one recipe rather than disabling
|
||||||
|
// automatic selection (REQ-BLD-AUTO-RECIPE).
|
||||||
|
PlacementFixture f(kFastBeltSpeed_tps, nullptr,
|
||||||
|
[](const std::string& id) { return id != "iron_ingot"; });
|
||||||
|
Tick tick = 0;
|
||||||
|
const BuildingId id = buildSmelter(f, QPoint(0, 0), tick);
|
||||||
|
|
||||||
|
f.belts.placeBelt(QPoint(2, 0), Rotation::West);
|
||||||
|
f.belts.tryPutItem(QPoint(2, 0), makeItem("copper_ore"));
|
||||||
|
f.belts.tick();
|
||||||
|
f.production.tickBeltPull(f.state, f.belts);
|
||||||
|
|
||||||
|
REQUIRE(findBuilding(f.state, id)->recipeId == "copper_ingot");
|
||||||
|
}
|
||||||
|
|
||||||
TEST_CASE("BuildingSystem: a manually selected recipe is not overridden", "[building]")
|
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 player's selection is a recipe like any other, so auto-selection stays out of
|
||||||
|
|||||||
Reference in New Issue
Block a user