diff --git a/docs/requirements.md b/docs/requirements.md index b15ab3c..b4e00f9 100644 --- a/docs/requirements.md +++ b/docs/requirements.md @@ -112,6 +112,12 @@ Modules in `modules.toml` define a `surface_mask` — a list of strings that des - REQ-BLD-DEMOLISH-CLICK: While in demolish mode (REQ-UI-HOTKEYS, REQ-UI-DEMOLISH-BUTTON), left-clicking a placed factory building or construction site in the game world demolishes it, following the refund rules of REQ-BLD-DEMOLISH — the partial refund for built buildings and the full refund for still-queued construction sites. Clicking a building that cannot be demolished (the HQ or a player defence station, per REQ-BLD-DEMOLISH), or clicking empty world space, has no effect. Demolish mode stays active after a demolition so the player can demolish further buildings without re-entering the mode; it is exited via the Q toggle (REQ-UI-HOTKEYS) or the Demolish button (REQ-UI-DEMOLISH-BUTTON). - REQ-BLD-DEMOLISH-BOX: While in demolish mode (REQ-UI-HOTKEYS, REQ-UI-DEMOLISH-BUTTON), the player can click and drag a selection box in the game world. A selection rectangle is drawn while dragging, using the same box-drag gesture and coverage semantics as the multi-select box (REQ-UI-MULTI-SELECT). On mouse up, every placed factory building and construction site covered by the box is demolished, each following the refund rules of REQ-BLD-DEMOLISH — the partial refund for built buildings and the full refund for still-queued construction sites. Buildings that cannot be demolished (the HQ and player defence stations, per REQ-BLD-DEMOLISH) are excluded from the box demolition; ships and defence stations are never affected. - REQ-BLD-SITE-CONFIG: A construction site — a building that has been placed but is still queued or under construction (REQ-BLD-QUEUE) — can be selected and configured exactly like the equivalent operational building, before it finishes building. Whatever configuration the building type supports is available on the site: the recipe for a Miner or Assembler (REQ-UI-SELECT-BUTTON), the produced-ship schematic and its module layout for a Shipyard (REQ-UI-SELECT-BUTTON, REQ-MOD-UI-PREVIEW, REQ-MOD-UI-DIALOG), and the output filters for a Splitter (REQ-BLD-SPLITTER) — all set through the same Selected Building Panel controls (REQ-UI-CONFIG-INLINE). Only currently unlocked recipes and schematics are offered, exactly as for operational buildings (REQ-LOCK-UI-RECIPE, REQ-LOCK-UI-SCHEMATIC, REQ-LOCK-UI-SPLITTER). The configuration is stored on the construction site and carries over unchanged when construction completes, so the building becomes operational already configured. A construction site has no input/output buffers and runs no production cycle, so the buffer and production-progress portions of the panel (REQ-UI-SINGLE-SELECTION, REQ-UI-PRODUCTION-PROGRESS) are not shown for it; only its construction progress (REQ-UI-CONSTRUCTION-PROGRESS) and its configuration controls appear. (Blueprint placement already applies a stored recipe or schematic to a construction site on placement per REQ-UI-BLUEPRINT-PLACE; this requirement additionally lets the player set or change that configuration directly on an existing site.) +- REQ-BLD-COPY-CONFIG: **Copy building settings (hold Shift).** While the Shift key is held, the player can copy one building's settings onto other buildings of the same type, so several identical machines can be set up without opening each one's panel. This gesture is available only in the default selection mode; while a builder, blueprint placement, or demolish mode is active it is disabled, so it never clashes with placement or demolition clicks. + - **Shift + right-click** a building copies its current settings into a temporary cache, along with the building's type. The settings copied are whatever that building type supports: the selected recipe (Miner, Assembler), the selected schematic together with its module layout (Shipyard), or the two output filters (Splitter, REQ-BLD-SPLITTER). Copying succeeds only when there is something to copy — a Miner or Assembler with a recipe selected, a Shipyard with a schematic selected, or any Splitter (whose output filters, even when empty/accept-all, always constitute valid settings). Shift + right-clicking a configurable building with nothing yet selected, a building type that has no settings at all (Smelter, Reprocessing Plant, Salvage Bay, belt/tunnel tiles, the HQ), or empty world space, has no effect and leaves any existing cache unchanged. + - **Shift + left-click** a building of the **same type** as the cached one applies the cached settings to it, exactly as if the player had made that selection through the selected building panel — with the same effects as a normal selection change (buffer clearing per REQ-MAT-INPUT-BUFFER and REQ-MAT-OUTPUT-BUFFER, and, for a Shipyard, in-progress cycle cancellation per REQ-BLD-SHIPYARD). This can be repeated on any number of same-type buildings while Shift stays held. Shift + left-clicking a building of a different type than the cached one, any building while the cache is empty, or empty world space, has no effect. + - Both operational buildings and construction sites take part as source and target (REQ-BLD-SITE-CONFIG); settings applied to a construction site carry over unchanged when it finishes building. + - **Releasing Shift clears the temporary cache.** It is never persisted and does not survive Shift being released; the next copy starts fresh. + - Because the cached settings were already valid on a same-type source building, they remain valid and available on the target (a selected recipe/schematic stays unlocked per REQ-LOCK-UI-RECIPE and REQ-LOCK-UI-SCHEMATIC; splitter filter item types stay unlocked per REQ-LOCK-UI-SPLITTER). ## Building Types 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;