From 92cfcee6820297b5a2633e21f41e5046f53b273b Mon Sep 17 00:00:00 2001 From: Malte Langkabel Date: Thu, 9 Jul 2026 16:21:59 +0200 Subject: [PATCH] Implement REQ-BLD-COPY-CONFIG: copy building settings with Shift MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Shift+right-click a building copies its settings (miner/assembler recipe, shipyard schematic + module layout, or splitter output filters) into a temporary UI cache; Shift+left-click stamps them onto another building of the same type. The gesture is active only in the default selection mode, works on operational buildings and construction sites, and the cache clears when Shift is released (or on Restart). Paste reuses the existing configuration commands (SetRecipe, SetShipLayout, SetSplitterFilters, SetSiteSplitterFilters), so it inherits their buffer- clearing / mid-cycle-cancel semantics and replays for free — no new command kinds or serializer changes. Extract the recipe/schematic/layout/filter reader into a shared readBuildingConfig() (lib/sim), handling buildings and sites alike, and reuse it from BlueprintPanel to remove the duplicated capture logic. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01VGNNLeFWhVzvxkK9qVXP2K --- src/lib/sim/BuildingConfig.cpp | 51 ++++++++++++ src/lib/sim/BuildingConfig.h | 40 ++++++++++ src/lib/sim/CMakeLists.txt | 2 + src/test/BuildingConfigTest.cpp | 133 ++++++++++++++++++++++++++++++++ src/test/CMakeLists.txt | 1 + src/ui/BlueprintPanel.cpp | 26 +++---- src/ui/GameWorldView.cpp | 101 ++++++++++++++++++++++++ src/ui/GameWorldView.h | 11 +++ 8 files changed, 352 insertions(+), 13 deletions(-) create mode 100644 src/lib/sim/BuildingConfig.cpp create mode 100644 src/lib/sim/BuildingConfig.h create mode 100644 src/test/BuildingConfigTest.cpp diff --git a/src/lib/sim/BuildingConfig.cpp b/src/lib/sim/BuildingConfig.cpp new file mode 100644 index 0000000..bc8b994 --- /dev/null +++ b/src/lib/sim/BuildingConfig.cpp @@ -0,0 +1,51 @@ +#include "BuildingConfig.h" + +#include "BeltSystem.h" +#include "Building.h" +#include "BuildingSystem.h" +#include "Simulation.h" + +std::optional readBuildingConfig(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; + } + + BuildingConfig config; + config.type = building ? building->type : site->type; + + const std::string& recipeId = building ? building->recipeId : site->recipeId; + if (!recipeId.empty()) + { + config.recipeId = recipeId; + } + + config.shipLayout = building ? building->shipLayout : site->shipLayout; + + if (config.type == BuildingType::Splitter) + { + config.isSplitter = true; + if (building) + { + // Operational splitter filters live in the BeltSystem, keyed by tile. + const std::optional info = + sim.belts().getSplitterInfo(building->anchor); + if (info.has_value()) + { + config.splitterFilterA = info->filterA; + config.splitterFilterB = info->filterB; + } + } + else + { + // A site keeps its pre-completion filters on the ConstructionSite. + config.splitterFilterA = site->splitterFilterA; + config.splitterFilterB = site->splitterFilterB; + } + } + + return config; +} diff --git a/src/lib/sim/BuildingConfig.h b/src/lib/sim/BuildingConfig.h new file mode 100644 index 0000000..c9c3840 --- /dev/null +++ b/src/lib/sim/BuildingConfig.h @@ -0,0 +1,40 @@ +#pragma once + +#include +#include +#include + +#include "BuildingId.h" +#include "BuildingType.h" +#include "ItemType.h" +#include "ShipLayout.h" + +class Simulation; + +// The user-configurable settings of a single building or construction site: the +// selected recipe / ship schematic, the shipyard module layout, and (for +// splitters) the two output filters. Shared by the copy-settings gesture +// (REQ-BLD-COPY-CONFIG) and blueprint capture (REQ-UI-BLUEPRINT-STORAGE). +struct BuildingConfig +{ + BuildingType type = BuildingType::Miner; + + // Selected recipe (Miner / Assembler) or ship schematic id (Shipyard); unset + // when nothing is selected. + std::optional recipeId; + + // Shipyard module layout (REQ-MOD-LAYOUT). + std::optional shipLayout; + + // Splitter output filters (empty = accept all). isSplitter distinguishes an + // empty-filter splitter (a valid accept-all configuration) from a building + // type that has no splitter filters at all. + bool isSplitter = false; + std::vector splitterFilterA; + std::vector splitterFilterB; +}; + +// Reads the current configuration of the building or construction site identified +// 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); diff --git a/src/lib/sim/CMakeLists.txt b/src/lib/sim/CMakeLists.txt index 24c819d..0c37cc5 100644 --- a/src/lib/sim/CMakeLists.txt +++ b/src/lib/sim/CMakeLists.txt @@ -10,6 +10,7 @@ SET(HDRS ${CMAKE_CURRENT_SOURCE_DIR}/TickDriver.h ${CMAKE_CURRENT_SOURCE_DIR}/BeltSystem.h ${CMAKE_CURRENT_SOURCE_DIR}/Building.h + ${CMAKE_CURRENT_SOURCE_DIR}/BuildingConfig.h ${CMAKE_CURRENT_SOURCE_DIR}/BuildingSystem.h ${CMAKE_CURRENT_SOURCE_DIR}/EntityHitTest.h ${CMAKE_CURRENT_SOURCE_DIR}/ShipLayout.h @@ -31,6 +32,7 @@ SET(SRCS ${CMAKE_CURRENT_SOURCE_DIR}/ReplayPlayer.cpp ${CMAKE_CURRENT_SOURCE_DIR}/TickDriver.cpp ${CMAKE_CURRENT_SOURCE_DIR}/BeltSystem.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/BuildingConfig.cpp ${CMAKE_CURRENT_SOURCE_DIR}/BuildingSystem.cpp ${CMAKE_CURRENT_SOURCE_DIR}/EntityHitTest.cpp ${CMAKE_CURRENT_SOURCE_DIR}/ShipStatsCalculator.cpp diff --git a/src/test/BuildingConfigTest.cpp b/src/test/BuildingConfigTest.cpp new file mode 100644 index 0000000..7df5f90 --- /dev/null +++ b/src/test/BuildingConfigTest.cpp @@ -0,0 +1,133 @@ +#include "catch.hpp" + +#include "Building.h" +#include "BuildingConfig.h" +#include "BuildingSystem.h" +#include "BuildingType.h" +#include "ConfigLoader.h" +#include "GameConfig.h" +#include "Rotation.h" +#include "ShipLayout.h" +#include "ShipsConfig.h" +#include "Simulation.h" +#include "SimulationTestAccess.h" + +// readBuildingConfig underpins the copy-settings gesture (REQ-BLD-COPY-CONFIG): +// it extracts a building's recipe / schematic / layout / splitter filters so they +// can be stamped onto a same-type building. It reads operational buildings and +// construction sites alike. + +namespace +{ +GameConfig loadConfig() +{ + return ConfigLoader::loadFromDirectory(CONFIG_DIR); +} + +const BuildingDef* findDef(const GameConfig& cfg, BuildingType type) +{ + for (const BuildingDef& def : cfg.buildings.buildings) + { + if (def.type == type) { return &def; } + } + return nullptr; +} + +BuildingId placeOperational(Simulation& sim, const GameConfig& cfg, + BuildingType type, QPoint anchor) +{ + const BuildingDef* def = findDef(cfg, type); + REQUIRE(def != nullptr); + return SimulationTestAccess::buildings(sim).placeImmediate( + type, def->surfaceMask, anchor, Rotation::East); +} + +const ShipDef* findAvailableSchematic(const GameConfig& cfg) +{ + for (const ShipDef& def : cfg.ships.ships) + { + if (def.unlockAtStationLevel == -1) { return &def; } + } + return nullptr; +} +} // namespace + +TEST_CASE("readBuildingConfig returns a miner's selected recipe", "[copyconfig]") +{ + const GameConfig cfg = loadConfig(); + Simulation sim(loadConfig(), 7); + + const BuildingId id = placeOperational(sim, cfg, BuildingType::Miner, QPoint(0, 0)); + SimulationTestAccess::buildings(sim).setRecipe(id, "mine_iron_ore"); + + const std::optional config = readBuildingConfig(sim, id); + REQUIRE(config.has_value()); + CHECK(config->type == BuildingType::Miner); + REQUIRE(config->recipeId.has_value()); + CHECK(*config->recipeId == "mine_iron_ore"); + CHECK_FALSE(config->isSplitter); + CHECK_FALSE(config->shipLayout.has_value()); +} + +TEST_CASE("readBuildingConfig leaves recipe unset when nothing is selected", + "[copyconfig]") +{ + const GameConfig cfg = loadConfig(); + Simulation sim(loadConfig(), 7); + + const BuildingId id = placeOperational(sim, cfg, BuildingType::Assembler, QPoint(0, 0)); + + const std::optional config = readBuildingConfig(sim, id); + REQUIRE(config.has_value()); + CHECK(config->type == BuildingType::Assembler); + CHECK_FALSE(config->recipeId.has_value()); // nothing to copy + CHECK_FALSE(config->isSplitter); +} + +TEST_CASE("readBuildingConfig returns a shipyard's schematic and layout", + "[copyconfig]") +{ + const GameConfig cfg = loadConfig(); + Simulation sim(loadConfig(), 7); + + const ShipDef* schematic = findAvailableSchematic(cfg); + REQUIRE(schematic != nullptr); + + const BuildingId id = placeOperational(sim, cfg, BuildingType::Shipyard, QPoint(0, 0)); + SimulationTestAccess::buildings(sim).setRecipe(id, schematic->id); + SimulationTestAccess::buildings(sim).setShipLayout(id, ShipLayoutConfig{}); + + const std::optional config = readBuildingConfig(sim, id); + REQUIRE(config.has_value()); + CHECK(config->type == BuildingType::Shipyard); + REQUIRE(config->recipeId.has_value()); + CHECK(*config->recipeId == schematic->id); + CHECK(config->shipLayout.has_value()); +} + +TEST_CASE("readBuildingConfig reads a queued construction site", "[copyconfig]") +{ + const GameConfig cfg = loadConfig(); + Simulation sim(loadConfig(), 7); + + // A placed miner enters the construction queue as a site (not yet operational). + const BuildingId id = + SimulationTestAccess::place(sim, BuildingType::Miner, QPoint(-2, 0), Rotation::East); + REQUIRE(id != kInvalidBuildingId); + REQUIRE(sim.buildings().findBuilding(id) == nullptr); + REQUIRE(sim.buildings().findSite(id) != nullptr); + + SimulationTestAccess::buildings(sim).setRecipe(id, "mine_iron_ore"); + + const std::optional config = readBuildingConfig(sim, id); + REQUIRE(config.has_value()); + CHECK(config->type == BuildingType::Miner); + REQUIRE(config->recipeId.has_value()); + CHECK(*config->recipeId == "mine_iron_ore"); +} + +TEST_CASE("readBuildingConfig returns nullopt for an unknown id", "[copyconfig]") +{ + Simulation sim(loadConfig(), 7); + CHECK_FALSE(readBuildingConfig(sim, kInvalidBuildingId).has_value()); +} diff --git a/src/test/CMakeLists.txt b/src/test/CMakeLists.txt index 4b09252..dd0a975 100644 --- a/src/test/CMakeLists.txt +++ b/src/test/CMakeLists.txt @@ -9,6 +9,7 @@ add_files( BeltSystemTest.cpp SurfaceMaskTest.cpp BuildingTest.cpp + BuildingConfigTest.cpp ShipTest.cpp ScrapTest.cpp BehaviorSystemTest.cpp diff --git a/src/ui/BlueprintPanel.cpp b/src/ui/BlueprintPanel.cpp index b3d984a..639dd86 100644 --- a/src/ui/BlueprintPanel.cpp +++ b/src/ui/BlueprintPanel.cpp @@ -19,6 +19,7 @@ #include "ExitBlueprintModeRequestedEvent.h" #include "Building.h" +#include "BuildingConfig.h" #include "BuildingSystem.h" #include "Simulation.h" @@ -188,20 +189,19 @@ Blueprint BlueprintPanel::createBlueprintFromSelection() const for (const Entry& e : entries) { BlueprintBuilding bb; - bb.type = e.building->type; - bb.rotation = e.building->rotation; - bb.offset = e.building->anchor - center; - bb.recipeId = e.building->recipeId; - bb.shipLayout = e.building->shipLayout; - if (e.building->type == BuildingType::Splitter) + 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()) { - const std::optional info = - m_sim->belts().getSplitterInfo(e.building->anchor); - if (info.has_value()) - { - bb.splitterFilterA = info->filterA; - bb.splitterFilterB = info->filterB; - } + 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); } diff --git a/src/ui/GameWorldView.cpp b/src/ui/GameWorldView.cpp index 1a4bc20..b5b28df 100644 --- a/src/ui/GameWorldView.cpp +++ b/src/ui/GameWorldView.cpp @@ -1617,6 +1617,8 @@ void GameWorldView::keyReleaseEvent(QKeyEvent* event) } if (event->key() == Qt::Key_A) { m_scrollLeft = false; } if (event->key() == Qt::Key_D) { m_scrollRight = false; } + // Releasing Shift discards the copied building settings (REQ-BLD-COPY-CONFIG). + if (event->key() == Qt::Key_Shift) { m_copiedConfig.reset(); } QOpenGLWidget::keyReleaseEvent(event); } @@ -1629,6 +1631,15 @@ void GameWorldView::mousePressEvent(QMouseEvent* event) if (m_builderType.has_value()) { exitBuilderMode(); } else if (m_blueprintMode.has_value()) { exitBlueprintMode(); } else if (m_demolishMode) { toggleDemolishMode(); } + else if (event->modifiers() & Qt::ShiftModifier) + { + // Shift + right-click copies a building's settings, but only in the + // default selection mode (REQ-BLD-COPY-CONFIG). + const QPoint tile = widgetToTile(event->pos()); + BuildingId id = buildingAtTile(tile); + if (id == kInvalidBuildingId) { id = siteAtTile(tile); } + if (id != kInvalidBuildingId) { copyConfigFrom(id); } + } } return; } @@ -1663,6 +1674,20 @@ void GameWorldView::mousePressEvent(QMouseEvent* event) } else { + // Shift + left-click applies the copied settings to a same-type building + // (REQ-BLD-COPY-CONFIG). Consumes the click so it does not change the + // selection. Only active in the default selection mode. + if ((event->modifiers() & Qt::ShiftModifier) && m_copiedConfig.has_value()) + { + BuildingId id = buildingAtTile(tile); + if (id == kInvalidBuildingId) { id = siteAtTile(tile); } + if (id != kInvalidBuildingId) + { + pasteConfigTo(id); + return; + } + } + const QVector2D worldPos = widgetToWorld(event->pos()); const entt::entity hitEntity = entityAtWorldPos(m_sim->admin(), worldPos); @@ -1868,6 +1893,81 @@ void GameWorldView::rotateGhost(bool clockwise) } } +void GameWorldView::copyConfigFrom(BuildingId id) +{ + const std::optional config = readBuildingConfig(*m_sim, id); + if (!config.has_value()) { return; } + + // Only cache when there is something to copy: a selected recipe / schematic + // (Miner, Assembler, Shipyard) or any Splitter (empty filters = accept-all). + // Building types with no settings (Smelter, Reprocessing Plant, Salvage Bay, + // belts / tunnels, HQ) leave any existing cache untouched (REQ-BLD-COPY-CONFIG). + if (!config->recipeId.has_value() && !config->isSplitter) { return; } + + m_copiedConfig = config; +} + +void GameWorldView::pasteConfigTo(BuildingId id) +{ + if (!m_copiedConfig.has_value()) { return; } + + const std::optional target = readBuildingConfig(*m_sim, id); + if (!target.has_value() || target->type != m_copiedConfig->type) { return; } + + const BuildingConfig& source = *m_copiedConfig; + + // The cached settings were valid on a same-type source building, so they are + // valid and available on the target: unlock state is global, so a selected + // recipe / schematic (REQ-LOCK-UI-RECIPE, REQ-LOCK-UI-SCHEMATIC) and splitter + // filter item types (REQ-LOCK-UI-SPLITTER) remain unlocked. Applying reuses the + // same configuration commands as the selected building panel, inheriting their + // buffer-clearing and mid-cycle-cancel semantics (REQ-MAT-INPUT-BUFFER, + // REQ-MAT-OUTPUT-BUFFER, REQ-BLD-SHIPYARD). + if (source.isSplitter) + { + // Operational splitters are configured by tile; sites by BuildingId + // (mirrors SelectedBuildingPanel::onSplitterFilterChanged). + if (const Building* building = m_sim->buildings().findBuilding(id)) + { + std::shared_ptr command = + std::make_shared(); + command->tile = building->anchor; + command->filterA = source.splitterFilterA; + command->filterB = source.splitterFilterB; + enqueueCommand(command); + } + else + { + std::shared_ptr command = + std::make_shared(); + command->id = id; + command->filterA = source.splitterFilterA; + command->filterB = source.splitterFilterB; + enqueueCommand(command); + } + return; + } + + if (source.recipeId.has_value()) + { + std::shared_ptr command = std::make_shared(); + command->id = id; + command->recipeId = *source.recipeId; + enqueueCommand(command); + } + + // For a shipyard the schematic (above) must be applied before its module + // layout, so both commands drain in order at the next tick boundary. + if (source.type == BuildingType::Shipyard && source.shipLayout.has_value()) + { + std::shared_ptr command = + std::make_shared(); + command->id = id; + command->layout = *source.shipLayout; + enqueueCommand(command); + } +} + void GameWorldView::enterBuilderMode(BuildingType type) { m_builderType = type; @@ -1940,6 +2040,7 @@ void GameWorldView::resetForNewGame() EventManager::getInstance()->sendEventImmediately( std::make_shared(false)); m_selectedBuildingIds.clear(); + m_copiedConfig = std::nullopt; m_boxSelecting = false; m_scrollXTiles = 0.0f; m_scrollLeft = false; diff --git a/src/ui/GameWorldView.h b/src/ui/GameWorldView.h index b4bf895..dd5ede1 100644 --- a/src/ui/GameWorldView.h +++ b/src/ui/GameWorldView.h @@ -14,6 +14,7 @@ #include #include "Blueprint.h" +#include "BuildingConfig.h" #include "BlueprintModeExitedEvent.h" #include "BlueprintPlacementRequestedEvent.h" #include "BuilderModeExitedEvent.h" @@ -165,6 +166,12 @@ private: void stepSpeed(int delta); void placeAtTile(QPoint tile); + // Copy-settings gesture (REQ-BLD-COPY-CONFIG): Shift+right-click copies a + // building's configuration into m_copiedConfig; Shift+left-click applies it to + // another building of the same type via the existing configuration commands. + void copyConfigFrom(BuildingId id); + void pasteConfigTo(BuildingId id); + void enterBuilderMode(BuildingType type); void exitBuilderMode(); void enterBlueprintMode(Blueprint blueprint); @@ -215,6 +222,10 @@ private: std::optional m_blueprintMode; QPoint m_blueprintGhostTile; + // Temporary cache for the copy-settings gesture (REQ-BLD-COPY-CONFIG); held + // only while Shift is down and cleared on Shift release. + std::optional m_copiedConfig; + bool m_demolishMode; BuildingId m_demolishHoverBuildingId; bool m_debugDraw;