diff --git a/docs/requirements.md b/docs/requirements.md index 1995983..f239cbd 100644 --- a/docs/requirements.md +++ b/docs/requirements.md @@ -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-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: - - **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. - **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). diff --git a/src/lib/sim/ProductionSystem.cpp b/src/lib/sim/ProductionSystem.cpp index 6e2edfe..5a8ff8c 100644 --- a/src/lib/sim/ProductionSystem.cpp +++ b/src/lib/sim/ProductionSystem.cpp @@ -16,10 +16,12 @@ ProductionSystem::ProductionSystem(const GameConfig& config, std::function&)> spawnShip, std::function isItemUnlocked, + std::function 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); diff --git a/src/lib/sim/ProductionSystem.h b/src/lib/sim/ProductionSystem.h index 3c80ebf..e5e9223 100644 --- a/src/lib/sim/ProductionSystem.h +++ b/src/lib/sim/ProductionSystem.h @@ -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&)> spawnShip, std::function isItemUnlocked, + std::function isRecipeUnlocked, std::mt19937& rng); // Advances every building's virtual input belts, delivers what arrives into the input @@ -93,12 +97,13 @@ private: std::vector 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&)> m_spawnShip; std::function m_isItemUnlocked; + std::function 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; diff --git a/src/lib/sim/Simulation.cpp b/src/lib/sim/Simulation.cpp index 8b95951..daa2783 100644 --- a/src/lib/sim/Simulation.cpp +++ b/src/lib/sim/Simulation.cpp @@ -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(m_config); m_deconstructionSystem = std::make_unique(m_config); diff --git a/src/test/BuildingTest.cpp b/src/test/BuildingTest.cpp index 3e1c896..34178f9 100644 --- a/src/test/BuildingTest.cpp +++ b/src/test/BuildingTest.cpp @@ -110,20 +110,29 @@ struct PlacementFixture 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 unlockedUnless( + std::function given) + { + return given ? std::move(given) + : std::function( + [](const std::string&) { return true; }); + } + // Defaults to the configured belt speed; pass kFastBeltSpeed_tps where the test - // needs items to arrive immediately. Everything counts as unlocked unless the test - // says otherwise, which is what the output-group eligibility rule turns on - // (REQ-LOCK-OUTPUT-POOL). + // needs items to arrive immediately. explicit PlacementFixture( std::optional beltSpeed_tps = std::nullopt, - std::function isItemUnlocked = nullptr) + std::function isItemUnlocked = nullptr, + std::function isRecipeUnlocked = nullptr) : belts(beltSpeed_tps.value_or(cfg.world.beltSpeed_tps)) , bs(cfg) , production(cfg, [](const std::string&, QVector2D, const std::optional&) {}, - isItemUnlocked ? std::move(isItemUnlocked) - : std::function( - [](const std::string&) { return true; }), + unlockedUnless(std::move(isItemUnlocked)), + unlockedUnless(std::move(isRecipeUnlocked)), 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()); } +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]") { // The player's selection is a recipe like any other, so auto-selection stays out of