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;