diff --git a/src/lib/sim/BuildingSystem.cpp b/src/lib/sim/BuildingSystem.cpp index 0d9a4d3..bd9d3c6 100644 --- a/src/lib/sim/BuildingSystem.cpp +++ b/src/lib/sim/BuildingSystem.cpp @@ -267,16 +267,23 @@ void BuildingSystem::initSalvageBayBuffer(Building& b) const } 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 : b.bodyCells) + for (const QPoint& cell : bodyCells) { bodySet.insert({cell.x(), cell.y()}); } std::set> outputPortTiles; - for (const Port& port : b.outputPorts) + for (const Port& port : outputPorts) { outputPortTiles.insert({port.tile.x(), port.tile.y()}); } @@ -294,7 +301,7 @@ std::vector BuildingSystem::computeInputPorts(const Building& b) const std::set> seen; std::vector inputPorts; - for (const QPoint& cell : b.bodyCells) + for (const QPoint& cell : bodyCells) { for (int i = 0; i < 4; ++i) { @@ -317,6 +324,30 @@ std::vector BuildingSystem::computeInputPorts(const Building& b) const return inputPorts; } +std::vector BuildingSystem::getInputPorts(BuildingId id) const +{ + if (const Building* building = findBuilding(id)) + { + return building->inputPorts; + } + if (const ConstructionSite* site = findSite(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 = 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) { std::vector eligible; diff --git a/src/lib/sim/BuildingSystem.h b/src/lib/sim/BuildingSystem.h index 64d31af..b27946a 100644 --- a/src/lib/sim/BuildingSystem.h +++ b/src/lib/sim/BuildingSystem.h @@ -171,6 +171,12 @@ public: // Find nearest operational building of the given type; nullptr if none. const Building* findNearestBuilding(QVector2D worldPos, BuildingType type) const; + // Input-capable adjacent tiles for a building or construction site + // (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); void unregisterTileOccupancy(const std::vector& cells); @@ -246,6 +252,9 @@ private: 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, diff --git a/src/test/BuildingTest.cpp b/src/test/BuildingTest.cpp index 4004f58..559b9ff 100644 --- a/src/test/BuildingTest.cpp +++ b/src/test/BuildingTest.cpp @@ -2,7 +2,9 @@ #include #include +#include #include +#include #include #include @@ -1421,3 +1423,106 @@ TEST_CASE("BuildingSystem: getProductionStatus classifies production state", "[b REQUIRE(statusOf(bay) == ProductionStatus::Producing); // holding scrap -> green } } + +// --------------------------------------------------------------------------- +// getInputPorts (REQ-BLD-BELT-DRAG snapping, REQ-MAT-INPUT-PORTS) +// --------------------------------------------------------------------------- + +namespace +{ + QPoint directionDelta(Rotation direction) + { + switch (direction) + { + case Rotation::North: return QPoint(0, -1); + case Rotation::East: return QPoint(1, 0); + case Rotation::South: return QPoint(0, 1); + case Rotation::West: return QPoint(-1, 0); + } + return QPoint(0, 0); + } + + bool hasInputPort(const std::vector& ports, QPoint tile, Rotation direction) + { + for (const Port& port : ports) + { + if (port.tile == tile && port.direction == direction) { return true; } + } + return false; + } + + // Advances the sim until the given site becomes an operational building, or a + // safety cap is reached. + void buildToCompletion(BuildingSystem& bs, BeltSystem& belts, BuildingId id, + Tick& tick) + { + for (int i = 0; i < 20000 && bs.findBuilding(id) == nullptr; ++i) + { + runTicks(bs, belts, 1, tick); + } + } +} + +TEST_CASE("BuildingSystem: getInputPorts on a miner site lists every input edge", "[building]") +{ + PlacementFixture f; + // 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); + + // Every perimeter edge except the output-port edge at (1,1), each pointing in. + REQUIRE(ports.size() == 6); + REQUIRE(hasInputPort(ports, QPoint(-1, 0), Rotation::East)); + REQUIRE(hasInputPort(ports, QPoint(0, -1), Rotation::South)); + REQUIRE(hasInputPort(ports, QPoint(2, 0), Rotation::West)); + REQUIRE(hasInputPort(ports, QPoint(1, -1), Rotation::South)); + REQUIRE(hasInputPort(ports, QPoint(-1, 1), Rotation::East)); + REQUIRE(hasInputPort(ports, QPoint(0, 2), Rotation::North)); + + // The output-port tile is never an input port. + REQUIRE_FALSE(hasInputPort(ports, QPoint(1, 1), Rotation::North)); + REQUIRE_FALSE(hasInputPort(ports, QPoint(1, 1), Rotation::West)); +} + +TEST_CASE("BuildingSystem: getInputPorts matches between a site and the built building", + "[building]") +{ + PlacementFixture f; + 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); + buildToCompletion(f.bs, f.belts, id, tick); + REQUIRE(f.bs.findBuilding(id) != nullptr); + const std::vector builtPorts = f.bs.getInputPorts(id); + + // The operational path (stored inputPorts) agrees with the site path (mask-derived). + REQUIRE(builtPorts.size() == sitePorts.size()); + for (const Port& port : sitePorts) + { + REQUIRE(hasInputPort(builtPorts, port.tile, port.direction)); + } +} + +TEST_CASE("BuildingSystem: getInputPorts invariants hold for a rotated site", "[building]") +{ + PlacementFixture f; + const BuildingId id = f.bs.place(BuildingType::Miner, QPoint(0, 0), Rotation::South, 0).value(); + const ConstructionSite* site = f.bs.findSite(id); + REQUIRE(site != nullptr); + + std::set> bodySet; + for (const QPoint& cell : site->bodyCells) { bodySet.insert({cell.x(), cell.y()}); } + + const std::vector ports = f.bs.getInputPorts(id); + REQUIRE_FALSE(ports.empty()); + for (const Port& port : ports) + { + // Each port tile is outside the footprint... + REQUIRE(bodySet.count({port.tile.x(), port.tile.y()}) == 0); + // ...and its direction points into an adjacent body cell. + const QPoint into = port.tile + directionDelta(port.direction); + REQUIRE(bodySet.count({into.x(), into.y()}) == 1); + } +} diff --git a/src/ui/GameWorldView.cpp b/src/ui/GameWorldView.cpp index ffe9dbb..4e3638c 100644 --- a/src/ui/GameWorldView.cpp +++ b/src/ui/GameWorldView.cpp @@ -937,7 +937,61 @@ void GameWorldView::placeAtTile(QPoint tile) void GameWorldView::recomputeBeltDragPath(QPoint cursorTile) { - m_beltDragPath = computeBeltDragPath(m_beltDragAnchor, cursorTile, m_ghostRotation); + QPoint endTile = cursorTile; + std::optional forcedEndRotation; + + // If the cursor is over a non-belt building or construction site, snap the end + // tile to the input-capable adjacent tile closest to the cursor, pointing into + // the target (REQ-BLD-BELT-DRAG). + std::optional targetId = buildingAtTile(cursorTile); + std::optional targetType; + if (targetId.has_value()) + { + if (const Building* building = m_sim->getBuildings().findBuilding(*targetId)) + { + targetType = building->type; + } + } + else if (std::optional siteId = siteAtTile(cursorTile); siteId.has_value()) + { + targetId = siteId; + if (const ConstructionSite* site = m_sim->getBuildings().findSite(*siteId)) + { + targetType = site->type; + } + } + + if (targetId.has_value() && targetType.has_value() + && *targetType != BuildingType::Belt) + { + const std::vector inputPorts = m_sim->getBuildings().getInputPorts(*targetId); + std::optional best; + float bestDistanceSq = 0.0f; + for (const Port& port : inputPorts) + { + const QVector2D center(static_cast(port.tile.x()) + 0.5f, + static_cast(port.tile.y()) + 0.5f); + const float distanceSq = (center - m_cursorWorldPos).lengthSquared(); + if (!best.has_value() || distanceSq < bestDistanceSq) + { + best = port; + bestDistanceSq = distanceSq; + } + } + if (best.has_value()) + { + endTile = best->tile; + forcedEndRotation = best->direction; + } + } + + m_beltDragPath = computeBeltDragPath(m_beltDragAnchor, endTile, m_ghostRotation); + if (forcedEndRotation.has_value() && !m_beltDragPath.empty()) + { + // The end tile points into the target, overriding its incoming-step + // orientation (REQ-BLD-BELT-DRAG "Snapping to a building"). + m_beltDragPath.back().rotation = *forcedEndRotation; + } } std::vector GameWorldView::resolveBeltDragPath() const @@ -2187,6 +2241,7 @@ void GameWorldView::mousePressEvent(QMouseEvent* event) // is placed until release (REQ-BLD-BELT-DRAG). m_dragging = true; m_beltDragAnchor = tile; + m_cursorWorldPos = widgetToWorld(event->pos()); recomputeBeltDragPath(tile); } else @@ -2350,6 +2405,7 @@ void GameWorldView::mousePressEvent(QMouseEvent* event) void GameWorldView::mouseMoveEvent(QMouseEvent* event) { const QPoint tile = widgetToTile(event->pos()); + m_cursorWorldPos = widgetToWorld(event->pos()); if (m_builderType.has_value()) { diff --git a/src/ui/GameWorldView.h b/src/ui/GameWorldView.h index 37ae794..3c1d8a8 100644 --- a/src/ui/GameWorldView.h +++ b/src/ui/GameWorldView.h @@ -279,6 +279,9 @@ private: // on release. Empty unless a belt drag is in progress. std::vector m_beltDragPath; QPoint m_beltDragAnchor; + // Last known cursor position in world (tile) units; used to pick the belt-drag + // end tile closest to the cursor when snapping to a building (REQ-BLD-BELT-DRAG). + QVector2D m_cursorWorldPos; bool m_dragging; std::optional m_blueprintMode;