From d87d063b10dfdb10fa4d2c4fe0f4b890916dfc70 Mon Sep 17 00:00:00 2001 From: mlangkabel Date: Wed, 5 Aug 2026 06:49:49 +0200 Subject: [PATCH] move the placement rules and the config-dependent queries off BuildingSystem --- src/lib/core/CMakeLists.txt | 1 + src/lib/core/PortGeometry.cpp | 57 ++++++++ src/lib/core/PortGeometry.h | 10 ++ src/lib/sim/BuildingSystem.cpp | 227 +------------------------------ src/lib/sim/BuildingSystem.h | 18 +-- src/lib/sim/CMakeLists.txt | 2 + src/lib/sim/FactoryQueries.cpp | 53 ++++++++ src/lib/sim/FactoryQueries.h | 21 ++- src/lib/sim/PlacementRules.cpp | 121 ++++++++++++++++ src/lib/sim/PlacementRules.h | 38 ++++++ src/lib/sim/Simulation.cpp | 3 +- src/test/BuildingTest.cpp | 44 +++--- src/ui/GameWorldView.cpp | 17 ++- src/ui/SelectedBuildingPanel.cpp | 2 +- 14 files changed, 340 insertions(+), 274 deletions(-) create mode 100644 src/lib/core/PortGeometry.cpp create mode 100644 src/lib/sim/PlacementRules.cpp create mode 100644 src/lib/sim/PlacementRules.h diff --git a/src/lib/core/CMakeLists.txt b/src/lib/core/CMakeLists.txt index dd67e69..a0d83f8 100644 --- a/src/lib/core/CMakeLists.txt +++ b/src/lib/core/CMakeLists.txt @@ -20,6 +20,7 @@ SET(HDRS SET(SRCS ${SRCS} ${CMAKE_CURRENT_SOURCE_DIR}/BuildingType.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/PortGeometry.cpp ${CMAKE_CURRENT_SOURCE_DIR}/EntityAdmin.cpp ${CMAKE_CURRENT_SOURCE_DIR}/DisplayName.cpp ${CMAKE_CURRENT_SOURCE_DIR}/BeltDragPath.cpp diff --git a/src/lib/core/PortGeometry.cpp b/src/lib/core/PortGeometry.cpp new file mode 100644 index 0000000..29c1d0f --- /dev/null +++ b/src/lib/core/PortGeometry.cpp @@ -0,0 +1,57 @@ +#include "PortGeometry.h" + +#include +#include + +std::vector computeInputPorts( + const std::vector& bodyCells, + const std::vector& outputPorts) +{ + // Build lookup sets for quick membership checks. + std::set> bodySet; + for (const QPoint& cell : bodyCells) + { + bodySet.insert({cell.x(), cell.y()}); + } + + std::set> outputPortTiles; + for (const Port& port : outputPorts) + { + outputPortTiles.insert({port.tile.x(), port.tile.y()}); + } + + // Neighbour deltas and the corresponding "inward" belt direction. + const int dx[4] = {-1, 1, 0, 0}; + const int dy[4] = { 0, 0, -1, 1}; + const Rotation inward[4] = { + Rotation::East, // neighbour is to the West; belt flows East toward building + Rotation::West, // neighbour is to the East; belt flows West toward building + Rotation::South, // neighbour is above (row-1); belt flows South toward building + Rotation::North // neighbour is below (row+1); belt flows North toward building + }; + + std::set> seen; + std::vector inputPorts; + + for (const QPoint& cell : bodyCells) + { + for (int i = 0; i < 4; ++i) + { + const int nx = cell.x() + dx[i]; + const int ny = cell.y() + dy[i]; + const std::pair neighbor = {nx, ny}; + + if (bodySet.count(neighbor)) { continue; } + if (outputPortTiles.count(neighbor)){ continue; } + if (seen.count(neighbor)) { continue; } + + seen.insert(neighbor); + Port port; + port.tile = QPoint(nx, ny); + port.direction = inward[i]; + inputPorts.push_back(port); + } + } + + return inputPorts; +} diff --git a/src/lib/core/PortGeometry.h b/src/lib/core/PortGeometry.h index dc18583..cf73f5d 100644 --- a/src/lib/core/PortGeometry.h +++ b/src/lib/core/PortGeometry.h @@ -1,7 +1,10 @@ #pragma once +#include + #include +#include "Port.h" #include "Rotation.h" // Geometry of a building's input/output ports. A Port names the tile *outside* the @@ -43,3 +46,10 @@ inline QPoint inputBodyTile(QPoint portTile, Rotation inwardDirection) } return portTile; } + +// Every belt-facing edge of a footprint that is not already an output port — the +// tiles a belt can feed the building from, with the direction items must flow to +// enter (REQ-MAT-INPUT-PORTS, REQ-BLD-BELT-DRAG). bodyCells and outputPorts are +// in absolute tile coordinates, and so is the result. +std::vector computeInputPorts(const std::vector& bodyCells, + const std::vector& outputPorts); diff --git a/src/lib/sim/BuildingSystem.cpp b/src/lib/sim/BuildingSystem.cpp index 4caec7f..0052d00 100644 --- a/src/lib/sim/BuildingSystem.cpp +++ b/src/lib/sim/BuildingSystem.cpp @@ -7,6 +7,7 @@ #include #include "FactoryQueries.h" +#include "PlacementRules.h" #include "ProductionRules.h" #include "PortGeometry.h" #include "StateChecksum.h" @@ -182,87 +183,6 @@ void BuildingSystem::initSalvageBayBuffer(Building& b) const (def && def->outputBufferCapacity) ? *def->outputBufferCapacity : 0; } -std::vector BuildingSystem::computeInputPorts(const Building& b) const -{ - return computeInputPorts(b.bodyCells, b.outputPorts); -} - -std::vector BuildingSystem::computeInputPorts( - const std::vector& bodyCells, - const std::vector& outputPorts) const -{ - // Build lookup sets for quick membership checks. - std::set> bodySet; - for (const QPoint& cell : bodyCells) - { - bodySet.insert({cell.x(), cell.y()}); - } - - std::set> outputPortTiles; - for (const Port& port : outputPorts) - { - outputPortTiles.insert({port.tile.x(), port.tile.y()}); - } - - // Neighbour deltas and the corresponding "inward" belt direction. - const int dx[4] = {-1, 1, 0, 0}; - const int dy[4] = { 0, 0, -1, 1}; - const Rotation inward[4] = { - Rotation::East, // neighbour is to the West; belt flows East toward building - Rotation::West, // neighbour is to the East; belt flows West toward building - Rotation::South, // neighbour is above (row-1); belt flows South toward building - Rotation::North // neighbour is below (row+1); belt flows North toward building - }; - - std::set> seen; - std::vector inputPorts; - - for (const QPoint& cell : bodyCells) - { - for (int i = 0; i < 4; ++i) - { - const int nx = cell.x() + dx[i]; - const int ny = cell.y() + dy[i]; - const std::pair neighbor = {nx, ny}; - - if (bodySet.count(neighbor)) { continue; } - if (outputPortTiles.count(neighbor)){ continue; } - if (seen.count(neighbor)) { continue; } - - seen.insert(neighbor); - Port port; - port.tile = QPoint(nx, ny); - port.direction = inward[i]; - inputPorts.push_back(port); - } - } - - return inputPorts; -} - -std::vector BuildingSystem::getInputPorts(BuildingId id) const -{ - if (const Building* building = findBuilding(m_state, id)) - { - return building->inputPorts; - } - if (const ConstructionSite* site = findSite(m_state, id)) - { - // A site stores no ports; derive its output ports from the mask (absolute) - // and run the same input-edge scan (REQ-BLD-BELT-DRAG, REQ-MAT-INPUT-PORTS). - const BuildingDef* def = m_config.buildings.findBuildingDef(site->type); - if (def == nullptr) { return {}; } - const ParsedSurfaceMask mask = parseSurfaceMask(def->surfaceMask, site->rotation); - std::vector outputPortsAbsolute; - outputPortsAbsolute.reserve(mask.outputPorts.size()); - for (const Port& port : mask.outputPorts) - { - outputPortsAbsolute.push_back(Port{ site->anchor + port.tile, port.direction }); - } - return computeInputPorts(site->bodyCells, outputPortsAbsolute); - } - return {}; -} std::vector BuildingSystem::rollReprocessingOutput(const RecipeDef& recipe) { @@ -301,7 +221,7 @@ std::optional BuildingSystem::place(BuildingType type, QPoint anchor const ParsedSurfaceMask mask = parseSurfaceMask(def->surfaceMask, rotation); // Reject placements that fall outside the world (REQ-BLD-PLACE-VALID). - if (!bodyCellsWithinWorldBounds(mask.bodyCells, anchor)) + if (!bodyCellsWithinWorldBounds(m_state, m_config, mask.bodyCells, anchor)) { return std::nullopt; } @@ -337,70 +257,6 @@ std::optional BuildingSystem::place(BuildingType type, QPoint anchor return id; } -bool BuildingSystem::bodyCellsWithinWorldBounds(const std::vector& bodyCells, - QPoint anchor) const -{ - const int heightTiles = m_config.world.heightTiles; - const int leftEdgeX = -m_state.asteroidWidth_tiles; - for (const QPoint& cell : bodyCells) - { - const QPoint worldCell = anchor + cell; - if (worldCell.y() < 0 || worldCell.y() >= heightTiles) - { - return false; - } - if (worldCell.x() < leftEdgeX) - { - return false; - } - } - return true; -} - -bool BuildingSystem::isPlacementValid(BuildingType type, QPoint anchor, - Rotation rotation) const -{ - const BuildingDef* def = m_config.buildings.findBuildingDef(type); - if (def == nullptr) - { - return false; - } - const ParsedSurfaceMask mask = parseSurfaceMask(def->surfaceMask, rotation); - - if (!bodyCellsWithinWorldBounds(mask.bodyCells, anchor)) - { - return false; - } - - // Terrain: ship-dock (S) cells must sit in space (x >= 0); all other body - // (A) cells must sit on the asteroid (x < 0). (REQ-BLD-PLACE-VALID) - for (const QPoint& cell : mask.bodyCells) - { - const QPoint worldCell = anchor + cell; - bool isShipDock = false; - for (const QPoint& dock : mask.shipDockCells) - { - if (dock == cell) - { - isShipDock = true; - break; - } - } - if (isShipDock) - { - if (worldCell.x() < 0) - { - return false; - } - } - else if (worldCell.x() >= 0) - { - return false; - } - } - return true; -} - // --------------------------------------------------------------------------- // Deconstruct // --------------------------------------------------------------------------- @@ -595,29 +451,6 @@ void BuildingSystem::setShipLayout(BuildingId id, const ShipLayoutConfig& layout } } -std::optional -BuildingSystem::getSiteSplitterInfo(BuildingId id) const -{ - for (const ConstructionSite& site : m_state.constructionQueue) - { - if (site.id != id) { continue; } - if (site.type != BuildingType::Splitter) { return std::nullopt; } - - const BuildingDef* def = m_config.buildings.findBuildingDef(site.type); - const ParsedSurfaceMask mask = parseSurfaceMask( - def ? def->surfaceMask : std::vector{}, site.rotation); - if (mask.outputPorts.size() < 2) { return std::nullopt; } - - BeltSystem::SplitterInfo info; - info.outputA = mask.outputPorts[0].direction; - info.outputB = mask.outputPorts[1].direction; - info.filterA = site.splitterFilterA; - info.filterB = site.splitterFilterB; - return info; - } - return std::nullopt; -} - void BuildingSystem::setSiteSplitterFilters(BuildingId id, const std::vector& filterA, const std::vector& filterB) @@ -690,7 +523,7 @@ void BuildingSystem::tickConstruction(Tick currentTick) building.outputPorts.push_back(absPort); } building.emergingItems.resize(building.outputPorts.size()); - building.inputPorts = computeInputPorts(building); + building.inputPorts = computeInputPorts(building.bodyCells, building.outputPorts); building.incomingItems.assign(building.inputPorts.size(), {}); if (building.type == BuildingType::SalvageBay) @@ -1263,56 +1096,6 @@ void BuildingSystem::forEachIncomingItem( -std::optional BuildingSystem::findRotateInPlaceTarget( - BuildingType type, QPoint anchor, Rotation rot) const -{ - // Tunnel Entries and Tunnel Exits cannot be rotated in place; re-orienting a - // tunnel requires deconstructing and re-placing it (REQ-BLD-ROTATE-IN-PLACE). - if (type == BuildingType::TunnelEntry || type == BuildingType::TunnelExit) - { - return std::nullopt; - } - - const BuildingDef* def = m_config.buildings.findBuildingDef(type); - if (!def) { return std::nullopt; } - - const ParsedSurfaceMask mask = parseSurfaceMask(def->surfaceMask, rot); - if (mask.bodyCells.empty()) { return std::nullopt; } - - // All body cells must be occupied by the same entity. - const QPoint firstAbs = anchor + mask.bodyCells[0]; - const std::optional firstOwner = m_state.grid.findOwner(firstAbs); - if (!firstOwner.has_value()) { return std::nullopt; } - const BuildingId candidateId = *firstOwner; - - for (const QPoint& rel : mask.bodyCells) - { - const std::optional owner = m_state.grid.findOwner(anchor + rel); - if (!owner.has_value() || *owner != candidateId) - { - return std::nullopt; - } - } - - // Verify the candidate is the same building type with the same cell count. - for (const ConstructionSite& site : m_state.constructionQueue) - { - if (site.id != candidateId) { continue; } - if (site.type != type) { return std::nullopt; } - if (site.bodyCells.size() != mask.bodyCells.size()) { return std::nullopt; } - return candidateId; - } - for (const Building& b : m_state.buildings) - { - if (b.id != candidateId) { continue; } - if (b.type != type) { return std::nullopt; } - if (b.bodyCells.size() != mask.bodyCells.size()) { return std::nullopt; } - return candidateId; - } - - return std::nullopt; -} - void BuildingSystem::rotateInPlace(BuildingId id, Rotation newRotation) { // Construction site path — just update rotation; no ports to recompute. @@ -1348,7 +1131,7 @@ void BuildingSystem::rotateInPlace(BuildingId id, Rotation newRotation) // the lanes to the new port set (REQ-MAT-OUTPUT-EMERGE). b.emergingItems.clear(); b.emergingItems.resize(b.outputPorts.size()); - b.inputPorts = computeInputPorts(b); + b.inputPorts = computeInputPorts(b.bodyCells, b.outputPorts); // Likewise discard in-transit input items and re-size the input belts to // the new port set (REQ-MAT-INPUT-INTAKE). b.incomingItems.assign(b.inputPorts.size(), {}); @@ -1406,7 +1189,7 @@ BuildingId BuildingSystem::placeImmediate(BuildingType type, building.outputPorts.push_back(absPort); } building.emergingItems.resize(building.outputPorts.size()); - building.inputPorts = computeInputPorts(building); + building.inputPorts = computeInputPorts(building.bodyCells, building.outputPorts); building.incomingItems.assign(building.inputPorts.size(), {}); if (type == BuildingType::SalvageBay) diff --git a/src/lib/sim/BuildingSystem.h b/src/lib/sim/BuildingSystem.h index b6f1257..78b15a0 100644 --- a/src/lib/sim/BuildingSystem.h +++ b/src/lib/sim/BuildingSystem.h @@ -16,6 +16,7 @@ #include "BeltSystem.h" #include "Building.h" #include "FactoryState.h" +#include "PlacementRules.h" #include "ProductionRules.h" #include "BuildingType.h" #include "BuildingId.h" @@ -60,8 +61,6 @@ public: // other body (A) cell sits on the asteroid (x < 0 and x >= the left edge), // and every cell has 0 <= y < world.height_tiles. There is no right-side // bound — space extends rightward. Tile occupancy is NOT checked here. - bool isPlacementValid(BuildingType type, QPoint anchor, - Rotation rotation) const; // Sets the current buildable asteroid width in tiles. Grows the left // placement bound as the player unlocks asteroid expansions (REQ-EXP-UNLOCK). @@ -99,7 +98,6 @@ public: // output directions (derived from its surface mask) and stored filters, or // nullopt if the id is not a Splitter site. The stored filters are applied // to BeltSystem when the splitter finishes building (tickConstruction). - std::optional getSiteSplitterInfo(BuildingId id) const; void setSiteSplitterFilters(BuildingId id, const std::vector& filterA, const std::vector& filterB); @@ -145,13 +143,6 @@ public: void forEachIncomingItem( const std::function& visit) const; - // Returns the entity id of the building or construction site whose footprint - // exactly coincides with the ghost (type, anchor, rot) and is of the same - // building type. Returns nullopt otherwise. - std::optional findRotateInPlaceTarget(BuildingType type, - QPoint anchor, - Rotation rot) const; - // Rotate an existing building or construction site to newRotation in place. // For belt-type operational buildings, re-registers with BeltSystem (items // currently on the tile are discarded by BeltSystem::removeTile). @@ -162,7 +153,6 @@ public: // (REQ-BLD-BELT-DRAG, REQ-MAT-INPUT-PORTS): each returned Port.tile is the // outside adjacent tile and Port.direction is the belt facing that points into // the target. Output-port edges are excluded. Empty for an unknown id. - std::vector getInputPorts(BuildingId id) const; // Register / unregister tile occupancy for ECS station entities. void registerTileOccupancy(const std::vector& cells, BuildingId ownerPlaceholder); @@ -238,14 +228,8 @@ private: void initAutoBuffers(Building& b) const; void initShipyardBuffers(Building& b) const; void initSalvageBayBuffer(Building& b) const; - std::vector computeInputPorts(const Building& b) const; // Core input-edge scan shared by operational buildings and construction sites. - std::vector computeInputPorts(const std::vector& bodyCells, - const std::vector& outputPorts) const; std::vector rollReprocessingOutput(const RecipeDef& recipe); - bool bodyCellsWithinWorldBounds( - const std::vector& bodyCells, - QPoint anchor) const; const GameConfig& m_config; diff --git a/src/lib/sim/CMakeLists.txt b/src/lib/sim/CMakeLists.txt index 479074e..351ed5b 100644 --- a/src/lib/sim/CMakeLists.txt +++ b/src/lib/sim/CMakeLists.txt @@ -16,6 +16,7 @@ SET(HDRS ${CMAKE_CURRENT_SOURCE_DIR}/FactoryState.h ${CMAKE_CURRENT_SOURCE_DIR}/FactoryQueries.h ${CMAKE_CURRENT_SOURCE_DIR}/ProductionRules.h + ${CMAKE_CURRENT_SOURCE_DIR}/PlacementRules.h ${CMAKE_CURRENT_SOURCE_DIR}/BuildingSystem.h ${CMAKE_CURRENT_SOURCE_DIR}/EntityHitTest.h ${CMAKE_CURRENT_SOURCE_DIR}/ShipLayout.h @@ -43,6 +44,7 @@ SET(SRCS ${CMAKE_CURRENT_SOURCE_DIR}/BuildingGrid.cpp ${CMAKE_CURRENT_SOURCE_DIR}/FactoryQueries.cpp ${CMAKE_CURRENT_SOURCE_DIR}/ProductionRules.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/PlacementRules.cpp ${CMAKE_CURRENT_SOURCE_DIR}/BuildingSystem.cpp ${CMAKE_CURRENT_SOURCE_DIR}/EntityHitTest.cpp ${CMAKE_CURRENT_SOURCE_DIR}/ShipStatsCalculator.cpp diff --git a/src/lib/sim/FactoryQueries.cpp b/src/lib/sim/FactoryQueries.cpp index 2d9eaf2..877af01 100644 --- a/src/lib/sim/FactoryQueries.cpp +++ b/src/lib/sim/FactoryQueries.cpp @@ -2,6 +2,9 @@ #include +#include "PortGeometry.h" +#include "SurfaceMask.h" + #include "Item.h" #include "ItemType.h" @@ -126,3 +129,53 @@ bool deliverScrapToSalvageBay(FactoryState& state, BuildingId bayId) bay->outputBuffer.items.push_back(Item{ItemType{"scrap"}}); return true; } + +std::vector getInputPorts(const FactoryState& state, const GameConfig& config, + BuildingId id) +{ + if (const Building* building = findBuilding(state, id)) + { + return building->inputPorts; + } + if (const ConstructionSite* site = findSite(state, id)) + { + // A site stores no ports; derive its output ports from the mask (absolute) + // and run the same input-edge scan (REQ-BLD-BELT-DRAG, REQ-MAT-INPUT-PORTS). + const BuildingDef* def = config.buildings.findBuildingDef(site->type); + if (def == nullptr) { return {}; } + const ParsedSurfaceMask mask = parseSurfaceMask(def->surfaceMask, site->rotation); + std::vector outputPortsAbsolute; + outputPortsAbsolute.reserve(mask.outputPorts.size()); + for (const Port& port : mask.outputPorts) + { + outputPortsAbsolute.push_back(Port{ site->anchor + port.tile, port.direction }); + } + return computeInputPorts(site->bodyCells, outputPortsAbsolute); + } + return {}; +} + + +std::optional +getSiteSplitterInfo(const FactoryState& state, const GameConfig& config, BuildingId id) +{ + for (const ConstructionSite& site : state.constructionQueue) + { + if (site.id != id) { continue; } + if (site.type != BuildingType::Splitter) { return std::nullopt; } + + const BuildingDef* def = config.buildings.findBuildingDef(site.type); + const ParsedSurfaceMask mask = parseSurfaceMask( + def ? def->surfaceMask : std::vector{}, site.rotation); + if (mask.outputPorts.size() < 2) { return std::nullopt; } + + BeltSystem::SplitterInfo info; + info.outputA = mask.outputPorts[0].direction; + info.outputB = mask.outputPorts[1].direction; + info.filterA = site.splitterFilterA; + info.filterB = site.splitterFilterB; + return info; + } + return std::nullopt; +} + diff --git a/src/lib/sim/FactoryQueries.h b/src/lib/sim/FactoryQueries.h index 442e0ba..5c65fec 100644 --- a/src/lib/sim/FactoryQueries.h +++ b/src/lib/sim/FactoryQueries.h @@ -8,15 +8,19 @@ #include "Building.h" #include "BuildingId.h" #include "BuildingType.h" +#include "BeltSystem.h" #include "FactoryState.h" +#include "GameConfig.h" +#include "Port.h" // Queries and operations over the factory's world data that need nothing but that // data — no config, no belts, no RNG. Free functions rather than BuildingSystem // methods so that callers depend on the data they read instead of on the system // that happens to tick it (see FactoryState.h). // -// A helper belongs here only if it is a pure function of FactoryState. Anything -// needing GameConfig or the asteroid bound stays on BuildingSystem for now. +// Most need nothing but the state. The two at the bottom also take the config, +// because answering them means reading a building definition — but still no belts, +// no RNG and no system. // The building with the given id, or nullptr when no building has it. Construction // sites are not buildings yet — use findSite for those. @@ -52,3 +56,16 @@ const Building* findNearestBuilding(const FactoryState& state, QVector2D worldPo // (REQ-BLD-DECON-QUEUE), or its holding capacity is already taken — emerging // scrap counts against that capacity (REQ-MAT-OUTPUT-EMERGE). bool deliverScrapToSalvageBay(FactoryState& state, BuildingId bayId); + +// Every belt-facing edge of the building or site with this id (REQ-MAT-INPUT-PORTS, +// REQ-BLD-BELT-DRAG). Empty when the id is unknown. A site has no stored ports, so +// they are derived from its surface mask. +std::vector getInputPorts(const FactoryState& state, const GameConfig& config, + BuildingId id); + +// The two output directions and stored filters of a queued Splitter site +// (REQ-BLD-SITE-CONFIG), or nullopt if the id is not one. Operational splitters are +// configured through BeltSystem by tile instead. +std::optional getSiteSplitterInfo(const FactoryState& state, + const GameConfig& config, + BuildingId id); diff --git a/src/lib/sim/PlacementRules.cpp b/src/lib/sim/PlacementRules.cpp new file mode 100644 index 0000000..ae6cff6 --- /dev/null +++ b/src/lib/sim/PlacementRules.cpp @@ -0,0 +1,121 @@ +#include "PlacementRules.h" + +#include "BuildingType.h" +#include "FactoryQueries.h" +#include "SurfaceMask.h" + +bool bodyCellsWithinWorldBounds(const FactoryState& state, const GameConfig& config,const std::vector& bodyCells, + QPoint anchor) +{ + const int heightTiles = config.world.heightTiles; + const int leftEdgeX = -state.asteroidWidth_tiles; + for (const QPoint& cell : bodyCells) + { + const QPoint worldCell = anchor + cell; + if (worldCell.y() < 0 || worldCell.y() >= heightTiles) + { + return false; + } + if (worldCell.x() < leftEdgeX) + { + return false; + } + } + return true; +} + +bool isPlacementValid(const FactoryState& state, const GameConfig& config,BuildingType type, QPoint anchor, + Rotation rotation) +{ + const BuildingDef* def = config.buildings.findBuildingDef(type); + if (def == nullptr) + { + return false; + } + const ParsedSurfaceMask mask = parseSurfaceMask(def->surfaceMask, rotation); + + if (!bodyCellsWithinWorldBounds(state, config, mask.bodyCells, anchor)) + { + return false; + } + + // Terrain: ship-dock (S) cells must sit in space (x >= 0); all other body + // (A) cells must sit on the asteroid (x < 0). (REQ-BLD-PLACE-VALID) + for (const QPoint& cell : mask.bodyCells) + { + const QPoint worldCell = anchor + cell; + bool isShipDock = false; + for (const QPoint& dock : mask.shipDockCells) + { + if (dock == cell) + { + isShipDock = true; + break; + } + } + if (isShipDock) + { + if (worldCell.x() < 0) + { + return false; + } + } + else if (worldCell.x() >= 0) + { + return false; + } + } + return true; +} + + +std::optional findRotateInPlaceTarget(const FactoryState& state, const GameConfig& config, + BuildingType type, QPoint anchor, Rotation rot) +{ + // Tunnel Entries and Tunnel Exits cannot be rotated in place; re-orienting a + // tunnel requires deconstructing and re-placing it (REQ-BLD-ROTATE-IN-PLACE). + if (type == BuildingType::TunnelEntry || type == BuildingType::TunnelExit) + { + return std::nullopt; + } + + const BuildingDef* def = config.buildings.findBuildingDef(type); + if (!def) { return std::nullopt; } + + const ParsedSurfaceMask mask = parseSurfaceMask(def->surfaceMask, rot); + if (mask.bodyCells.empty()) { return std::nullopt; } + + // All body cells must be occupied by the same entity. + const QPoint firstAbs = anchor + mask.bodyCells[0]; + const std::optional firstOwner = state.grid.findOwner(firstAbs); + if (!firstOwner.has_value()) { return std::nullopt; } + const BuildingId candidateId = *firstOwner; + + for (const QPoint& rel : mask.bodyCells) + { + const std::optional owner = state.grid.findOwner(anchor + rel); + if (!owner.has_value() || *owner != candidateId) + { + return std::nullopt; + } + } + + // Verify the candidate is the same building type with the same cell count. + for (const ConstructionSite& site : state.constructionQueue) + { + if (site.id != candidateId) { continue; } + if (site.type != type) { return std::nullopt; } + if (site.bodyCells.size() != mask.bodyCells.size()) { return std::nullopt; } + return candidateId; + } + for (const Building& b : state.buildings) + { + if (b.id != candidateId) { continue; } + if (b.type != type) { return std::nullopt; } + if (b.bodyCells.size() != mask.bodyCells.size()) { return std::nullopt; } + return candidateId; + } + + return std::nullopt; +} + diff --git a/src/lib/sim/PlacementRules.h b/src/lib/sim/PlacementRules.h new file mode 100644 index 0000000..38437ab --- /dev/null +++ b/src/lib/sim/PlacementRules.h @@ -0,0 +1,38 @@ +#pragma once + +#include +#include + +#include + +#include "BuildingId.h" +#include "BuildingType.h" +#include "FactoryState.h" +#include "GameConfig.h" +#include "Rotation.h" + +// Where a building may be placed, and what is already sitting on those tiles. +// Free functions over the factory state and the config — they read no other +// system state, so they do not belong to BuildingSystem. + +// True if every body cell lies inside the world: 0 <= y < world.height_tiles and +// x >= the current asteroid left edge (REQ-BLD-PLACE-VALID). Terrain type is not +// checked — see isPlacementValid for the full rule. +bool bodyCellsWithinWorldBounds(const FactoryState& state, const GameConfig& config, + const std::vector& bodyCells, QPoint anchor); + +// True if the placement satisfies REQ-BLD-PLACE-VALID terrain and world-bounds +// rules: every ship-dock (S) cell sits in space (x >= 0), every other body (A) +// cell sits on the asteroid (x < 0 and x >= the left edge), and every cell has +// 0 <= y < world.height_tiles. There is no right-side bound — space extends +// rightward. Tile occupancy is NOT checked here. +bool isPlacementValid(const FactoryState& state, const GameConfig& config, + BuildingType type, QPoint anchor, Rotation rotation); + +// The building or site that a ghost of the given type/anchor/rotation would +// rotate in place rather than replace: same type, same body cells, one owner +// (REQ-BLD-ROTATE-IN-PLACE). Tunnels never qualify. +std::optional findRotateInPlaceTarget(const FactoryState& state, + const GameConfig& config, + BuildingType type, QPoint anchor, + Rotation rot); diff --git a/src/lib/sim/Simulation.cpp b/src/lib/sim/Simulation.cpp index 37ff2af..d7042f2 100644 --- a/src/lib/sim/Simulation.cpp +++ b/src/lib/sim/Simulation.cpp @@ -1,6 +1,7 @@ #include "Simulation.h" #include "FactoryQueries.h" +#include "PlacementRules.h" #include #include @@ -860,7 +861,7 @@ std::optional Simulation::tryPlaceBuilding(BuildingType type, QPoint return std::nullopt; } - if (!m_buildingSystem->isPlacementValid(type, anchor, rotation)) + if (!isPlacementValid(m_factoryState, m_config, type, anchor, rotation)) { return std::nullopt; } diff --git a/src/test/BuildingTest.cpp b/src/test/BuildingTest.cpp index 2184151..6303b1a 100644 --- a/src/test/BuildingTest.cpp +++ b/src/test/BuildingTest.cpp @@ -1,4 +1,5 @@ #include "catch.hpp" +#include "PlacementRules.h" #include "FactoryQueries.h" #include "ProductionRules.h" @@ -201,19 +202,18 @@ TEST_CASE("BuildingSystem: isPlacementValid enforces terrain and world bounds", const int leftEdgeX = -f.cfg.world.regions.asteroidWidth_tiles; // Miner is all-asteroid (A): valid only fully on the asteroid (x < 0). - REQUIRE(f.bs.isPlacementValid(BuildingType::Miner, QPoint(-3, 0), Rotation::East)); - REQUIRE_FALSE(f.bs.isPlacementValid(BuildingType::Miner, QPoint(0, 0), Rotation::East)); // A cells in space - REQUIRE_FALSE(f.bs.isPlacementValid(BuildingType::Miner, QPoint(0, -1), Rotation::East)); // above world - REQUIRE(f.bs.isPlacementValid(BuildingType::Miner, QPoint(leftEdgeX, 0), Rotation::East)); - REQUIRE_FALSE(f.bs.isPlacementValid(BuildingType::Miner, - QPoint(leftEdgeX - 1, 0), Rotation::East)); // past left edge + REQUIRE(isPlacementValid(f.state, f.cfg, BuildingType::Miner, QPoint(-3, 0), Rotation::East)); + REQUIRE_FALSE(isPlacementValid(f.state, f.cfg, BuildingType::Miner, QPoint(0, 0), Rotation::East)); // A cells in space + REQUIRE_FALSE(isPlacementValid(f.state, f.cfg, BuildingType::Miner, QPoint(0, -1), Rotation::East)); // above world + REQUIRE(isPlacementValid(f.state, f.cfg, BuildingType::Miner, QPoint(leftEdgeX, 0), Rotation::East)); + REQUIRE_FALSE(isPlacementValid(f.state, f.cfg, BuildingType::Miner, QPoint(leftEdgeX - 1, 0), Rotation::East)); // past left edge // Shipyard mask ["AAAS>","AAAS "] straddles the boundary: A cells on the // asteroid, the S (dock) cell in space. At anchor (-3,0) the A cells land at // x=-3..-1 and the dock at x=0. - REQUIRE(f.bs.isPlacementValid(BuildingType::Shipyard, QPoint(-3, 0), Rotation::East)); - REQUIRE_FALSE(f.bs.isPlacementValid(BuildingType::Shipyard, QPoint(0, 0), Rotation::East)); // A cells in space - REQUIRE_FALSE(f.bs.isPlacementValid(BuildingType::Shipyard, QPoint(-4, 0), Rotation::East)); // dock on asteroid + REQUIRE(isPlacementValid(f.state, f.cfg, BuildingType::Shipyard, QPoint(-3, 0), Rotation::East)); + REQUIRE_FALSE(isPlacementValid(f.state, f.cfg, BuildingType::Shipyard, QPoint(0, 0), Rotation::East)); // A cells in space + REQUIRE_FALSE(isPlacementValid(f.state, f.cfg, BuildingType::Shipyard, QPoint(-4, 0), Rotation::East)); // dock on asteroid } TEST_CASE("BuildingSystem: placing a belt registers it with BeltSystem after construction", @@ -1192,7 +1192,7 @@ TEST_CASE("BuildingSystem: findRotateInPlaceTarget returns nullopt when tile is rng); REQUIRE_FALSE( - bs.findRotateInPlaceTarget(BuildingType::Belt, QPoint(0, 0), Rotation::East).has_value()); + findRotateInPlaceTarget(state_bs, cfg, BuildingType::Belt, QPoint(0, 0), Rotation::East).has_value()); } TEST_CASE("BuildingSystem: findRotateInPlaceTarget returns the site id for a queued belt (same type, different rotation)", @@ -1214,7 +1214,7 @@ TEST_CASE("BuildingSystem: findRotateInPlaceTarget returns the site id for a que const BuildingId id = bs.place(BuildingType::Belt, QPoint(0, 0), Rotation::East, 0).value(); const std::optional result = - bs.findRotateInPlaceTarget(BuildingType::Belt, QPoint(0, 0), Rotation::North); + findRotateInPlaceTarget(state_bs, cfg, BuildingType::Belt, QPoint(0, 0), Rotation::North); REQUIRE(result.has_value()); REQUIRE(*result == id); } @@ -1242,7 +1242,7 @@ TEST_CASE("BuildingSystem: findRotateInPlaceTarget returns the building id for a REQUIRE(getAllSites(state_bs).empty()); const std::optional result = - bs.findRotateInPlaceTarget(BuildingType::Belt, QPoint(0, 0), Rotation::South); + findRotateInPlaceTarget(state_bs, cfg, BuildingType::Belt, QPoint(0, 0), Rotation::South); REQUIRE(result.has_value()); REQUIRE(*result == id); } @@ -1267,7 +1267,7 @@ TEST_CASE("BuildingSystem: findRotateInPlaceTarget returns nullopt when building // Querying with Splitter at the same tile — type mismatch → nullopt. REQUIRE_FALSE( - bs.findRotateInPlaceTarget(BuildingType::Splitter, QPoint(0, 0), Rotation::East).has_value()); + findRotateInPlaceTarget(state_bs, cfg, BuildingType::Splitter, QPoint(0, 0), Rotation::East).has_value()); } TEST_CASE("BuildingSystem: findRotateInPlaceTarget never rotates a tunnel in place", @@ -1292,9 +1292,9 @@ TEST_CASE("BuildingSystem: findRotateInPlaceTarget never rotates a tunnel in pla bs.place(BuildingType::TunnelExit, QPoint(-2, 0), Rotation::East, 0); REQUIRE_FALSE( - bs.findRotateInPlaceTarget(BuildingType::TunnelEntry, QPoint(-1, 0), Rotation::North).has_value()); + findRotateInPlaceTarget(state_bs, cfg, BuildingType::TunnelEntry, QPoint(-1, 0), Rotation::North).has_value()); REQUIRE_FALSE( - bs.findRotateInPlaceTarget(BuildingType::TunnelExit, QPoint(-2, 0), Rotation::North).has_value()); + findRotateInPlaceTarget(state_bs, cfg, BuildingType::TunnelExit, QPoint(-2, 0), Rotation::North).has_value()); } TEST_CASE("BuildingSystem: findRotateInPlaceTarget returns nullopt when footprints only partially overlap", @@ -1319,7 +1319,7 @@ TEST_CASE("BuildingSystem: findRotateInPlaceTarget returns nullopt when footprin // Ghost anchored at (1,0) would cover (1,0),(2,0),(1,1),(2,1): // only (1,0) and (1,1) are occupied — not a full coincidence. REQUIRE_FALSE( - bs.findRotateInPlaceTarget(BuildingType::Smelter, QPoint(1, 0), Rotation::East).has_value()); + findRotateInPlaceTarget(state_bs, cfg, BuildingType::Smelter, QPoint(1, 0), Rotation::East).has_value()); } TEST_CASE("BuildingSystem: findRotateInPlaceTarget works for a symmetric multi-tile building with rotated ghost", @@ -1343,7 +1343,7 @@ TEST_CASE("BuildingSystem: findRotateInPlaceTarget works for a symmetric multi-t const BuildingId id = bs.place(BuildingType::Smelter, QPoint(0, 0), Rotation::East, 0).value(); const std::optional result = - bs.findRotateInPlaceTarget(BuildingType::Smelter, QPoint(0, 0), Rotation::North); + findRotateInPlaceTarget(state_bs, cfg, BuildingType::Smelter, QPoint(0, 0), Rotation::North); REQUIRE(result.has_value()); REQUIRE(*result == id); } @@ -1507,7 +1507,7 @@ TEST_CASE("BuildingSystem: splitter filters configured on a construction site ca // The site reports its two output directions and the stored filters before // it is built; it is not yet registered with BeltSystem. - const std::optional siteInfo = f.bs.getSiteSplitterInfo(id); + const std::optional siteInfo = getSiteSplitterInfo(f.state, f.cfg, id); REQUIRE(siteInfo.has_value()); REQUIRE(siteInfo->filterA == filterA); REQUIRE(siteInfo->filterB.empty()); @@ -1687,7 +1687,7 @@ TEST_CASE("BuildingSystem: getInputPorts on a miner site lists every input edge" // Miner mask ["AA","A>"] East → body (0,0),(1,0),(0,1); output tile (1,1) East. const BuildingId id = f.bs.place(BuildingType::Miner, QPoint(0, 0), Rotation::East, 0).value(); - const std::vector ports = f.bs.getInputPorts(id); + const std::vector ports = getInputPorts(f.state, f.cfg, id); // Every perimeter edge except the output-port edge at (1,1), each pointing in. REQUIRE(ports.size() == 6); @@ -1710,10 +1710,10 @@ TEST_CASE("BuildingSystem: getInputPorts matches between a site and the built bu Tick tick = 0; const BuildingId id = f.bs.place(BuildingType::Miner, QPoint(0, 0), Rotation::East, 0).value(); - const std::vector sitePorts = f.bs.getInputPorts(id); + const std::vector sitePorts = getInputPorts(f.state, f.cfg, id); buildToCompletion(f.bs, f.state, f.belts, id, tick); REQUIRE(findBuilding(f.state, id) != nullptr); - const std::vector builtPorts = f.bs.getInputPorts(id); + const std::vector builtPorts = getInputPorts(f.state, f.cfg, id); // The operational path (stored inputPorts) agrees with the site path (mask-derived). REQUIRE(builtPorts.size() == sitePorts.size()); @@ -1733,7 +1733,7 @@ TEST_CASE("BuildingSystem: getInputPorts invariants hold for a rotated site", "[ std::set> bodySet; for (const QPoint& cell : site->bodyCells) { bodySet.insert({cell.x(), cell.y()}); } - const std::vector ports = f.bs.getInputPorts(id); + const std::vector ports = getInputPorts(f.state, f.cfg, id); REQUIRE_FALSE(ports.empty()); for (const Port& port : ports) { diff --git a/src/ui/GameWorldView.cpp b/src/ui/GameWorldView.cpp index 12ebe23..3951be7 100644 --- a/src/ui/GameWorldView.cpp +++ b/src/ui/GameWorldView.cpp @@ -1,4 +1,5 @@ #include "GameWorldView.h" +#include "PlacementRules.h" #include "FactoryQueries.h" #include "ProductionRules.h" @@ -662,7 +663,7 @@ bool GameWorldView::isValidPlacement(BuildingType type, QPoint anchor, // Terrain and world-bounds validity are owned by the simulation // (REQ-BLD-PLACE-VALID); the presentation layer only adds the occupancy / // rotate-in-place check. - if (!m_sim->getBuildings().isPlacementValid(type, anchor, rot)) + if (!isPlacementValid(m_sim->getFactoryState(), m_sim->getConfig(), type, anchor, rot)) { return false; } @@ -683,7 +684,7 @@ bool GameWorldView::isValidPlacement(BuildingType type, QPoint anchor, if (anyOccupied) { - return m_sim->getBuildings().findRotateInPlaceTarget(type, anchor, rot).has_value(); + return findRotateInPlaceTarget(m_sim->getFactoryState(), m_sim->getConfig(), type, anchor, rot).has_value(); } return true; } @@ -865,8 +866,7 @@ void GameWorldView::placeBlueprintAtTile(QPoint center) for (const BlueprintBuilding& bb : bp.buildings) { if (!m_sim->isBuildingUnlocked(bb.type)) { continue; } - if (m_sim->getBuildings().findRotateInPlaceTarget( - bb.type, center + bb.offset, bb.rotation).has_value()) + if (findRotateInPlaceTarget(m_sim->getFactoryState(), m_sim->getConfig(), bb.type, center + bb.offset, bb.rotation).has_value()) { continue; } @@ -880,7 +880,7 @@ void GameWorldView::placeBlueprintAtTile(QPoint center) if (!m_sim->isBuildingUnlocked(bb.type)) { continue; } const QPoint anchor = center + bb.offset; const std::optional rotateTarget = - m_sim->getBuildings().findRotateInPlaceTarget(bb.type, anchor, bb.rotation); + findRotateInPlaceTarget(m_sim->getFactoryState(), m_sim->getConfig(), bb.type, anchor, bb.rotation); if (rotateTarget.has_value()) { std::shared_ptr rotateCommand = @@ -1016,7 +1016,7 @@ void GameWorldView::placeAtTile(QPoint tile) } const std::optional rotateTarget = - m_sim->getBuildings().findRotateInPlaceTarget(type, tile, m_ghostRotation); + findRotateInPlaceTarget(m_sim->getFactoryState(), m_sim->getConfig(), type, tile, m_ghostRotation); if (rotateTarget.has_value()) { std::shared_ptr command = @@ -1080,7 +1080,7 @@ void GameWorldView::recomputeBeltDragPath(QPoint cursorTile) if (targetId.has_value() && targetType.has_value() && *targetType != BuildingType::Belt) { - const std::vector inputPorts = m_sim->getBuildings().getInputPorts(*targetId); + const std::vector inputPorts = getInputPorts(m_sim->getFactoryState(), m_sim->getConfig(), *targetId); std::optional best; float bestDistanceSq = 0.0f; for (const Port& port : inputPorts) @@ -1124,8 +1124,7 @@ std::vector GameWorldView::resolveBeltDragPath( { BeltDragResolved item; const std::optional rotateTarget = - m_sim->getBuildings().findRotateInPlaceTarget( - BuildingType::Belt, entry.tile, entry.rotation); + findRotateInPlaceTarget(m_sim->getFactoryState(), m_sim->getConfig(), BuildingType::Belt, entry.tile, entry.rotation); if (rotateTarget.has_value()) { // A tile holding only a belt (or belt site) is re-oriented, no cost. diff --git a/src/ui/SelectedBuildingPanel.cpp b/src/ui/SelectedBuildingPanel.cpp index e262c15..86f8963 100644 --- a/src/ui/SelectedBuildingPanel.cpp +++ b/src/ui/SelectedBuildingPanel.cpp @@ -314,7 +314,7 @@ void SelectedBuildingPanel::buildSingle(BuildingId id) std::optional info; if (m_singleIsSite) { - info = m_sim->getBuildings().getSiteSplitterInfo(id); + info = getSiteSplitterInfo(m_sim->getFactoryState(), m_sim->getConfig(), id); } else {