From ba0c10d54ece650ae0d0a8e94a50cc74672c0504 Mon Sep 17 00:00:00 2001 From: Malte Langkabel Date: Mon, 13 Jul 2026 19:35:17 +0200 Subject: [PATCH] Allow creating blueprints from construction sites Blueprint capture and the Create Blueprint enable check previously resolved each selected id via findBuilding() only, silently dropping construction sites. Extract the capture logic into two testable lib/sim helpers (captureBlueprintFromSelection, selectionHasPlaceableBuilding) that resolve each id as an operational building or a construction site alike, reusing readBuildingConfig for recipe/schematic/layout/splitter config. BlueprintPanel now delegates to them. Add regression tests covering site capture, site recipe capture, mixed operational+site selection, and the placeable-selection predicate. Implements REQ-UI-BLUEPRINT-CREATE / REQ-UI-BLUEPRINT-STORAGE. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01DZR44tA8sn4dPqDzAVXyps --- src/lib/sim/BuildingConfig.cpp | 116 +++++++++++++++++++++++++++++++++ src/lib/sim/BuildingConfig.h | 14 ++++ src/test/BlueprintTest.cpp | 87 +++++++++++++++++++++++++ src/ui/BlueprintPanel.cpp | 73 ++------------------- 4 files changed, 223 insertions(+), 67 deletions(-) diff --git a/src/lib/sim/BuildingConfig.cpp b/src/lib/sim/BuildingConfig.cpp index bc8b994..8d1bb90 100644 --- a/src/lib/sim/BuildingConfig.cpp +++ b/src/lib/sim/BuildingConfig.cpp @@ -1,10 +1,55 @@ #include "BuildingConfig.h" +#include +#include + #include "BeltSystem.h" #include "Building.h" #include "BuildingSystem.h" #include "Simulation.h" +namespace +{ +// The blueprint-relevant geometry shared by operational buildings and construction +// sites. Resolved from whichever of the two a selected id refers to. +struct SelectedBuilding +{ + BuildingId id; + BuildingType type; + Rotation rotation; + QPoint anchor; + const std::vector* bodyCells; +}; + +// Resolves a selected id to a player-placeable building or construction site, if it +// is one. Returns std::nullopt for an unknown id or a non-player-placeable building +// (the HQ and defence stations, per REQ-UI-BLUEPRINT-CREATE). +std::optional resolvePlaceable(const Simulation& sim, BuildingId id) +{ + const Building* building = sim.buildings().findBuilding(id); + const ConstructionSite* site = building ? nullptr : sim.buildings().findSite(id); + if (!building && !site) + { + return std::nullopt; + } + + const BuildingType type = building ? building->type : site->type; + const BuildingDef* def = sim.config().buildings.findBuildingDef(type); + if (!def || !def->playerPlaceable) + { + return std::nullopt; + } + + SelectedBuilding resolved; + resolved.id = id; + resolved.type = type; + resolved.rotation = building ? building->rotation : site->rotation; + resolved.anchor = building ? building->anchor : site->anchor; + resolved.bodyCells = building ? &building->bodyCells : &site->bodyCells; + return resolved; +} +} // namespace + std::optional readBuildingConfig(const Simulation& sim, BuildingId id) { const Building* building = sim.buildings().findBuilding(id); @@ -49,3 +94,74 @@ std::optional readBuildingConfig(const Simulation& sim, Building return config; } + +Blueprint captureBlueprintFromSelection(const Simulation& sim, + const std::vector& selectedIds) +{ + std::vector entries; + entries.reserve(selectedIds.size()); + for (const BuildingId id : selectedIds) + { + const std::optional resolved = resolvePlaceable(sim, id); + if (resolved.has_value()) + { + entries.push_back(*resolved); + } + } + + if (entries.empty()) + { + return Blueprint{}; + } + + int minX = INT_MAX, maxX = INT_MIN; + int minY = INT_MAX, maxY = INT_MIN; + for (const SelectedBuilding& e : entries) + { + for (const QPoint& cell : *e.bodyCells) + { + minX = std::min(minX, cell.x()); + maxX = std::max(maxX, cell.x()); + minY = std::min(minY, cell.y()); + maxY = std::max(maxY, cell.y()); + } + } + + const QPoint center((minX + maxX) / 2, (minY + maxY) / 2); + + Blueprint blueprint; + blueprint.buildings.reserve(entries.size()); + for (const SelectedBuilding& e : entries) + { + BlueprintBuilding building; + building.type = e.type; + building.rotation = e.rotation; + building.offset = e.anchor - center; + // Recipe / schematic / layout / splitter-filter capture is shared with the + // copy-settings gesture (REQ-BLD-COPY-CONFIG) via readBuildingConfig, which + // handles operational buildings and construction sites alike. + const std::optional config = readBuildingConfig(sim, e.id); + if (config.has_value()) + { + building.recipeId = config->recipeId.value_or(std::string()); + building.shipLayout = config->shipLayout; + building.splitterFilterA = config->splitterFilterA; + building.splitterFilterB = config->splitterFilterB; + } + blueprint.buildings.push_back(building); + } + return blueprint; +} + +bool selectionHasPlaceableBuilding(const Simulation& sim, + const std::vector& selectedIds) +{ + for (const BuildingId id : selectedIds) + { + if (resolvePlaceable(sim, id).has_value()) + { + return true; + } + } + return false; +} diff --git a/src/lib/sim/BuildingConfig.h b/src/lib/sim/BuildingConfig.h index c9c3840..d333372 100644 --- a/src/lib/sim/BuildingConfig.h +++ b/src/lib/sim/BuildingConfig.h @@ -4,6 +4,7 @@ #include #include +#include "Blueprint.h" #include "BuildingId.h" #include "BuildingType.h" #include "ItemType.h" @@ -38,3 +39,16 @@ struct BuildingConfig // by id, handling operational buildings and sites alike. Returns std::nullopt if // no such building or site exists. std::optional readBuildingConfig(const Simulation& sim, BuildingId id); + +// Captures a blueprint from a selection of building / construction-site ids, keeping +// only player-placeable buildings and recording each one's type, rotation, offset +// from the selection's bounding-box center, and configuration. Operational buildings +// and construction sites are treated identically (REQ-UI-BLUEPRINT-CREATE, +// REQ-UI-BLUEPRINT-STORAGE). The returned blueprint is unnamed. +Blueprint captureBlueprintFromSelection(const Simulation& sim, + const std::vector& selectedIds); + +// True if any selected id refers to a player-placeable building or construction site +// (the enable condition for the Create Blueprint button, REQ-UI-BLUEPRINT-CREATE). +bool selectionHasPlaceableBuilding(const Simulation& sim, + const std::vector& selectedIds); diff --git a/src/test/BlueprintTest.cpp b/src/test/BlueprintTest.cpp index 9fdb467..3f10df4 100644 --- a/src/test/BlueprintTest.cpp +++ b/src/test/BlueprintTest.cpp @@ -8,6 +8,7 @@ #include "Blueprint.h" #include "Building.h" +#include "BuildingConfig.h" #include "BuildingsConfig.h" #include "BuildingSystem.h" #include "BuildingType.h" @@ -696,6 +697,92 @@ TEST_CASE("Blueprint placement: recipe transfers to building after construction REQUIRE(b->recipeId == "mine_copper_ore"); } +// --------------------------------------------------------------------------- +// Blueprint capture from construction sites (REQ-UI-BLUEPRINT-CREATE, +// REQ-UI-BLUEPRINT-STORAGE): a still-under-construction building is a valid +// blueprint source, captured identically to an operational building. +// --------------------------------------------------------------------------- + +TEST_CASE("Blueprint creation: a construction site is captured", "[blueprint]") +{ + Simulation sim(loadConfig()); + + // Freshly placed → a ConstructionSite (not ticked to completion). A 1x1 belt keeps + // the body-cell bounding-box centered on the anchor, so a single site → zero offset. + const BuildingId id = + SimulationTestAccess::place(sim, BuildingType::Belt, QPoint(-2, 0), Rotation::East); + REQUIRE(id != kInvalidBuildingId); + REQUIRE(sim.buildings().findSite(id) != nullptr); + REQUIRE(sim.buildings().findBuilding(id) == nullptr); + + const Blueprint bp = captureBlueprintFromSelection(sim, { id }); + + REQUIRE(bp.buildings.size() == 1); + REQUIRE(bp.buildings[0].type == BuildingType::Belt); + REQUIRE(bp.buildings[0].offset == QPoint(0, 0)); // single 1x1 building → zero offset +} + +TEST_CASE("Blueprint creation: a construction site's recipe is captured", "[blueprint]") +{ + Simulation sim(loadConfig()); + + const BuildingId id = + SimulationTestAccess::place(sim, BuildingType::Miner, QPoint(-2, 0), Rotation::East); + REQUIRE(id != kInvalidBuildingId); + SimulationTestAccess::buildings(sim).setRecipe(id, "mine_iron_ore"); + + const Blueprint bp = captureBlueprintFromSelection(sim, { id }); + + REQUIRE(bp.buildings.size() == 1); + REQUIRE(bp.buildings[0].recipeId == "mine_iron_ore"); +} + +TEST_CASE("Blueprint creation: mixed operational building and construction site are both captured", + "[blueprint]") +{ + Simulation sim(loadConfig()); + + // Building A: place, configure, and tick to completion so it is operational. + const BuildingId idA = + SimulationTestAccess::place(sim, BuildingType::Miner, QPoint(-2, 0), Rotation::East); + REQUIRE(idA != kInvalidBuildingId); + SimulationTestAccess::buildings(sim).setRecipe(idA, "mine_iron_ore"); + for (int i = 0; i <= static_cast(secondsToTicks(10.0)); ++i) { sim.tick(); } + REQUIRE(sim.buildings().findBuilding(idA) != nullptr); + + // Building B: place and configure, but leave as a construction site. + const BuildingId idB = + SimulationTestAccess::place(sim, BuildingType::Miner, QPoint(-6, 0), Rotation::East); + REQUIRE(idB != kInvalidBuildingId); + SimulationTestAccess::buildings(sim).setRecipe(idB, "mine_copper_ore"); + REQUIRE(sim.buildings().findSite(idB) != nullptr); + + const Blueprint bp = captureBlueprintFromSelection(sim, { idA, idB }); + + REQUIRE(bp.buildings.size() == 2); + REQUIRE(bp.buildings[0].type == BuildingType::Miner); + REQUIRE(bp.buildings[1].type == BuildingType::Miner); + // Both the operational building's and the site's configs come through. + std::vector recipes = { bp.buildings[0].recipeId, bp.buildings[1].recipeId }; + std::sort(recipes.begin(), recipes.end()); + REQUIRE(recipes == std::vector{ "mine_copper_ore", "mine_iron_ore" }); + // Distinct anchors → distinct offsets. + REQUIRE(bp.buildings[0].offset != bp.buildings[1].offset); +} + +TEST_CASE("Blueprint creation: selectionHasPlaceableBuilding sees a construction site", "[blueprint]") +{ + Simulation sim(loadConfig()); + + REQUIRE_FALSE(selectionHasPlaceableBuilding(sim, {})); + + const BuildingId id = + SimulationTestAccess::place(sim, BuildingType::Miner, QPoint(-2, 0), Rotation::East); + REQUIRE(id != kInvalidBuildingId); + REQUIRE(sim.buildings().findSite(id) != nullptr); + REQUIRE(selectionHasPlaceableBuilding(sim, { id })); +} + TEST_CASE("Blueprint placement: interceptor schematic is unlocked at game start", "[blueprint]") { // "interceptor" has unlock_at_station_level = -1 in the test config. diff --git a/src/ui/BlueprintPanel.cpp b/src/ui/BlueprintPanel.cpp index 2f71331..ea2753a 100644 --- a/src/ui/BlueprintPanel.cpp +++ b/src/ui/BlueprintPanel.cpp @@ -146,60 +146,10 @@ void BlueprintPanel::onBlueprintButtonClicked(int index) Blueprint BlueprintPanel::createBlueprintFromSelection() const { - struct Entry - { - const Building* building; - }; - std::vector entries; - entries.reserve(m_selectedBuildingIds.size()); - - for (const BuildingId id : m_selectedBuildingIds) - { - const Building* b = m_sim->buildings().findBuilding(id); - if (!b) { continue; } - const BuildingDef* def = m_config->buildings.findBuildingDef(b->type); - if (def && def->playerPlaceable) { entries.push_back({ b }); } - } - - if (entries.empty()) { return Blueprint{}; } - - int minX = INT_MAX, maxX = INT_MIN; - int minY = INT_MAX, maxY = INT_MIN; - for (const Entry& e : entries) - { - for (const QPoint& cell : e.building->bodyCells) - { - minX = std::min(minX, cell.x()); - maxX = std::max(maxX, cell.x()); - minY = std::min(minY, cell.y()); - maxY = std::max(maxY, cell.y()); - } - } - - const QPoint center((minX + maxX) / 2, (minY + maxY) / 2); - - Blueprint bp; - bp.buildings.reserve(entries.size()); - for (const Entry& e : entries) - { - BlueprintBuilding bb; - bb.type = e.building->type; - bb.rotation = e.building->rotation; - bb.offset = e.building->anchor - center; - // Recipe / schematic / layout / splitter-filter capture is shared with the - // copy-settings gesture (REQ-BLD-COPY-CONFIG) via readBuildingConfig. - const std::optional config = - readBuildingConfig(*m_sim, e.building->id); - if (config.has_value()) - { - bb.recipeId = config->recipeId.value_or(std::string()); - bb.shipLayout = config->shipLayout; - bb.splitterFilterA = config->splitterFilterA; - bb.splitterFilterB = config->splitterFilterB; - } - bp.buildings.push_back(bb); - } - return bp; + // Capture is shared, testable logic in lib/sim: it resolves each selected id as an + // operational building or a construction site alike (REQ-UI-BLUEPRINT-CREATE, + // REQ-UI-BLUEPRINT-STORAGE). + return captureBlueprintFromSelection(*m_sim, m_selectedBuildingIds); } int BlueprintPanel::computeBlueprintCost(const Blueprint& bp) const @@ -295,19 +245,8 @@ void BlueprintPanel::loadFromDisk() void BlueprintPanel::refreshButtonStates() { - const bool anyPlaceable = [&]() { - for (const BuildingId id : m_selectedBuildingIds) - { - const Building* b = m_sim->buildings().findBuilding(id); - if (!b) { continue; } - for (const BuildingDef& def : m_config->buildings.buildings) - { - if (def.type == b->type) { return def.playerPlaceable; } - } - } - return false; - }(); - m_createBtn->setEnabled(anyPlaceable); + // A construction site counts the same as an operational building (REQ-UI-BLUEPRINT-CREATE). + m_createBtn->setEnabled(selectionHasPlaceableBuilding(*m_sim, m_selectedBuildingIds)); for (int i = 0; i < static_cast(m_blueprintButtons.size()); ++i) {