diff --git a/src/lib/sim/BuildingSystem.cpp b/src/lib/sim/BuildingSystem.cpp index 1c8d8e3..5e855c7 100644 --- a/src/lib/sim/BuildingSystem.cpp +++ b/src/lib/sim/BuildingSystem.cpp @@ -797,6 +797,112 @@ bool BuildingSystem::isTileOccupied(QPoint tile) const return m_tileOccupancy.count({tile.x(), tile.y()}) > 0; } +std::optional BuildingSystem::findRotateInPlaceTarget( + BuildingType type, QPoint anchor, Rotation rot) const +{ + const BuildingDef* def = 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 auto firstIt = m_tileOccupancy.find({firstAbs.x(), firstAbs.y()}); + if (firstIt == m_tileOccupancy.end()) { return std::nullopt; } + const EntityId candidateId = firstIt->second; + + for (const QPoint& rel : mask.bodyCells) + { + const QPoint abs = anchor + rel; + const auto it = m_tileOccupancy.find({abs.x(), abs.y()}); + if (it == m_tileOccupancy.end() || it->second != candidateId) + { + return std::nullopt; + } + } + + // Verify the candidate is the same building type with the same cell count. + for (const ConstructionSite& site : m_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_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(EntityId id, Rotation newRotation) +{ + // Construction site path — just update rotation; no ports to recompute. + for (ConstructionSite& site : m_constructionQueue) + { + if (site.id == id) + { + site.rotation = newRotation; + return; + } + } + + // Operational building path. + for (Building& b : m_buildings) + { + if (b.id != id) { continue; } + + b.rotation = newRotation; + + const BuildingDef* def = findBuildingDef(b.type); + if (!def) { return; } + const ParsedSurfaceMask mask = parseSurfaceMask(def->surfaceMask, newRotation); + + b.outputPorts.clear(); + for (const Port& port : mask.outputPorts) + { + Port absPort; + absPort.tile = b.anchor + port.tile; + absPort.direction = port.direction; + b.outputPorts.push_back(absPort); + } + b.inputPorts = computeInputPorts(b); + + // Re-register with BeltSystem (items on tile are discarded). + if (b.type == BuildingType::Belt) + { + m_belts.removeTile(b.anchor); + m_belts.placeBelt(b.anchor, newRotation); + } + else if (b.type == BuildingType::Splitter) + { + m_belts.removeTile(b.anchor); + assert(mask.outputPorts.size() >= 2); + m_belts.placeSplitter(b.anchor, + mask.outputPorts[0].direction, + mask.outputPorts[1].direction); + } + else if (b.type == BuildingType::TunnelEntry) + { + m_belts.removeTile(b.anchor); + m_belts.placeTunnelEntry(b.anchor, newRotation, m_config.world.tunnelMaxDistance); + } + else if (b.type == BuildingType::TunnelExit) + { + m_belts.removeTile(b.anchor); + m_belts.placeTunnelExit(b.anchor, newRotation); + } + + return; + } +} + const Building* BuildingSystem::findNearestBuilding(QVector2D worldPos, BuildingType type) const { diff --git a/src/lib/sim/BuildingSystem.h b/src/lib/sim/BuildingSystem.h index 1c899f7..9ceb2e1 100644 --- a/src/lib/sim/BuildingSystem.h +++ b/src/lib/sim/BuildingSystem.h @@ -74,6 +74,18 @@ public: std::vector allBeltTiles() const; bool isTileOccupied(QPoint tile) 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). + void rotateInPlace(EntityId id, Rotation newRotation); + // Find nearest operational building of the given type; nullptr if none. const Building* findNearestBuilding(QVector2D worldPos, BuildingType type) const; diff --git a/src/test/BuildingTest.cpp b/src/test/BuildingTest.cpp index b918329..089df37 100644 --- a/src/test/BuildingTest.cpp +++ b/src/test/BuildingTest.cpp @@ -535,3 +535,245 @@ TEST_CASE("BuildingSystem: reprocessing plant produces one cycle output then sta // No new production: inputs were consumed and not replenished. REQUIRE_FALSE(b->production.has_value()); } + +// --------------------------------------------------------------------------- +// findRotateInPlaceTarget +// --------------------------------------------------------------------------- + +TEST_CASE("BuildingSystem: findRotateInPlaceTarget returns nullopt when tile is empty", + "[building][rotate-in-place]") +{ + const GameConfig cfg = loadConfig(); + BeltSystem belts(cfg.world.beltSpeedTilesPerSecond); + int stock = 0; + std::mt19937 rng(0); + EntityId nextId = 1; + BuildingSystem bs(cfg, belts, + [&nextId]() { return nextId++; }, + [&stock](int n) { stock += n; }, + [](const std::string&, QVector2D) {}, + rng); + + REQUIRE_FALSE( + bs.findRotateInPlaceTarget(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)", + "[building][rotate-in-place]") +{ + const GameConfig cfg = loadConfig(); + BeltSystem belts(cfg.world.beltSpeedTilesPerSecond); + int stock = 0; + std::mt19937 rng(0); + EntityId nextId = 1; + BuildingSystem bs(cfg, belts, + [&nextId]() { return nextId++; }, + [&stock](int n) { stock += n; }, + [](const std::string&, QVector2D) {}, + rng); + + const EntityId id = bs.place(BuildingType::Belt, QPoint(0, 0), Rotation::East, 0); + + const std::optional result = + bs.findRotateInPlaceTarget(BuildingType::Belt, QPoint(0, 0), Rotation::North); + REQUIRE(result.has_value()); + REQUIRE(*result == id); +} + +TEST_CASE("BuildingSystem: findRotateInPlaceTarget returns the building id for a completed operational belt", + "[building][rotate-in-place]") +{ + const GameConfig cfg = loadConfig(); + BeltSystem belts(cfg.world.beltSpeedTilesPerSecond); + int stock = 0; + std::mt19937 rng(0); + EntityId nextId = 1; + BuildingSystem bs(cfg, belts, + [&nextId]() { return nextId++; }, + [&stock](int n) { stock += n; }, + [](const std::string&, QVector2D) {}, + rng); + + const EntityId id = bs.place(BuildingType::Belt, QPoint(0, 0), Rotation::East, 0); + + Tick tick = 0; + runTicks(bs, belts, static_cast(secondsToTicks(1.0)) + 1, tick); + REQUIRE(bs.allSites().empty()); + + const std::optional result = + bs.findRotateInPlaceTarget(BuildingType::Belt, QPoint(0, 0), Rotation::South); + REQUIRE(result.has_value()); + REQUIRE(*result == id); +} + +TEST_CASE("BuildingSystem: findRotateInPlaceTarget returns nullopt when building type differs", + "[building][rotate-in-place]") +{ + const GameConfig cfg = loadConfig(); + BeltSystem belts(cfg.world.beltSpeedTilesPerSecond); + int stock = 0; + std::mt19937 rng(0); + EntityId nextId = 1; + BuildingSystem bs(cfg, belts, + [&nextId]() { return nextId++; }, + [&stock](int n) { stock += n; }, + [](const std::string&, QVector2D) {}, + rng); + + bs.place(BuildingType::Belt, QPoint(0, 0), Rotation::East, 0); + + // Querying with Splitter at the same tile — type mismatch → nullopt. + REQUIRE_FALSE( + bs.findRotateInPlaceTarget(BuildingType::Splitter, QPoint(0, 0), Rotation::East).has_value()); +} + +TEST_CASE("BuildingSystem: findRotateInPlaceTarget returns nullopt when footprints only partially overlap", + "[building][rotate-in-place]") +{ + const GameConfig cfg = loadConfig(); + BeltSystem belts(cfg.world.beltSpeedTilesPerSecond); + int stock = 0; + std::mt19937 rng(0); + EntityId nextId = 1; + BuildingSystem bs(cfg, belts, + [&nextId]() { return nextId++; }, + [&stock](int n) { stock += n; }, + [](const std::string&, QVector2D) {}, + rng); + + // Smelter at (0,0) occupies body tiles (0,0),(1,0),(0,1),(1,1). + bs.place(BuildingType::Smelter, QPoint(0, 0), Rotation::East, 0); + + // 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()); +} + +TEST_CASE("BuildingSystem: findRotateInPlaceTarget works for a symmetric multi-tile building with rotated ghost", + "[building][rotate-in-place]") +{ + const GameConfig cfg = loadConfig(); + BeltSystem belts(cfg.world.beltSpeedTilesPerSecond); + int stock = 0; + std::mt19937 rng(0); + EntityId nextId = 1; + BuildingSystem bs(cfg, belts, + [&nextId]() { return nextId++; }, + [&stock](int n) { stock += n; }, + [](const std::string&, QVector2D) {}, + rng); + + // Smelter is a fully filled 2×2 footprint — rotating the ghost produces the + // same four body tiles, so findRotateInPlaceTarget must still return the id. + const EntityId id = bs.place(BuildingType::Smelter, QPoint(0, 0), Rotation::East, 0); + + const std::optional result = + bs.findRotateInPlaceTarget(BuildingType::Smelter, QPoint(0, 0), Rotation::North); + REQUIRE(result.has_value()); + REQUIRE(*result == id); +} + +// --------------------------------------------------------------------------- +// rotateInPlace +// --------------------------------------------------------------------------- + +TEST_CASE("BuildingSystem: rotateInPlace updates the rotation field of a construction site", + "[building][rotate-in-place]") +{ + const GameConfig cfg = loadConfig(); + BeltSystem belts(cfg.world.beltSpeedTilesPerSecond); + int stock = 0; + std::mt19937 rng(0); + EntityId nextId = 1; + BuildingSystem bs(cfg, belts, + [&nextId]() { return nextId++; }, + [&stock](int n) { stock += n; }, + [](const std::string&, QVector2D) {}, + rng); + + const EntityId id = bs.place(BuildingType::Belt, QPoint(0, 0), Rotation::East, 0); + REQUIRE(bs.findSite(id)->rotation == Rotation::East); + + bs.rotateInPlace(id, Rotation::North); + + REQUIRE(bs.findSite(id)->rotation == Rotation::North); +} + +TEST_CASE("BuildingSystem: rotateInPlace preserves the construction progress of a queued site", + "[building][rotate-in-place]") +{ + const GameConfig cfg = loadConfig(); + BeltSystem belts(cfg.world.beltSpeedTilesPerSecond); + int stock = 0; + std::mt19937 rng(0); + EntityId nextId = 1; + BuildingSystem bs(cfg, belts, + [&nextId]() { return nextId++; }, + [&stock](int n) { stock += n; }, + [](const std::string&, QVector2D) {}, + rng); + + const EntityId id = bs.place(BuildingType::Belt, QPoint(0, 0), Rotation::East, 0); + const Tick completesAt = bs.findSite(id)->completesAt; + REQUIRE(completesAt > 0); + + bs.rotateInPlace(id, Rotation::South); + + REQUIRE(bs.findSite(id)->completesAt == completesAt); +} + +TEST_CASE("BuildingSystem: rotateInPlace updates rotation and output port direction on an operational building", + "[building][rotate-in-place]") +{ + const GameConfig cfg = loadConfig(); + BeltSystem belts(cfg.world.beltSpeedTilesPerSecond); + int stock = 0; + std::mt19937 rng(0); + EntityId nextId = 1; + BuildingSystem bs(cfg, belts, + [&nextId]() { return nextId++; }, + [&stock](int n) { stock += n; }, + [](const std::string&, QVector2D) {}, + rng); + + const EntityId id = bs.place(BuildingType::Belt, QPoint(0, 0), Rotation::East, 0); + + Tick tick = 0; + runTicks(bs, belts, static_cast(secondsToTicks(1.0)) + 1, tick); + REQUIRE(bs.findBuilding(id) != nullptr); + + const Building& before = *bs.findBuilding(id); + REQUIRE(before.outputPorts[0].direction == Rotation::East); + + bs.rotateInPlace(id, Rotation::North); + + const Building& after = *bs.findBuilding(id); + REQUIRE(after.rotation == Rotation::North); + REQUIRE(after.outputPorts[0].direction == Rotation::North); +} + +TEST_CASE("BuildingSystem: rotateInPlace re-registers a belt tile with BeltSystem so it still accepts items", + "[building][rotate-in-place]") +{ + const GameConfig cfg = loadConfig(); + BeltSystem belts(cfg.world.beltSpeedTilesPerSecond); + int stock = 0; + std::mt19937 rng(0); + EntityId nextId = 1; + BuildingSystem bs(cfg, belts, + [&nextId]() { return nextId++; }, + [&stock](int n) { stock += n; }, + [](const std::string&, QVector2D) {}, + rng); + + const EntityId id = bs.place(BuildingType::Belt, QPoint(0, 0), Rotation::East, 0); + + Tick tick = 0; + runTicks(bs, belts, static_cast(secondsToTicks(1.0)) + 1, tick); + + bs.rotateInPlace(id, Rotation::North); + + // Belt tile must still be registered after rotation — items can be placed on it. + REQUIRE(belts.tryPutItem(QPoint(0, 0), makeItem("iron_ore"))); +} diff --git a/src/ui/GameWorldView.cpp b/src/ui/GameWorldView.cpp index bfd12f6..a8e21dd 100644 --- a/src/ui/GameWorldView.cpp +++ b/src/ui/GameWorldView.cpp @@ -384,6 +384,7 @@ bool GameWorldView::isValidPlacement(BuildingType type, QPoint anchor, const ParsedSurfaceMask parsed = parseSurfaceMask(def->surfaceMask, rot); + bool anyOccupied = false; for (const QPoint& relCell : parsed.bodyCells) { const QPoint worldCell = anchor + relCell; @@ -401,8 +402,12 @@ bool GameWorldView::isValidPlacement(BuildingType type, QPoint anchor, if (isShipDock && worldCell.x() < 0) { return false; } if (!isShipDock && worldCell.x() >= 0) { return false; } - // Occupancy check - if (m_sim->buildings().isTileOccupied(worldCell)) { return false; } + if (m_sim->buildings().isTileOccupied(worldCell)) { anyOccupied = true; } + } + + if (anyOccupied) + { + return m_sim->buildings().findRotateInPlaceTarget(type, anchor, rot).has_value(); } return true; } @@ -481,9 +486,15 @@ void GameWorldView::placeBlueprintAtTile(QPoint center) if (!isValidPlacement(bb.type, center + bb.offset, bb.rotation)) { return; } } + // Cost only applies to buildings that are genuinely new (not rotate-in-place). int totalCost = 0; for (const BlueprintBuilding& bb : bp.buildings) { + if (m_sim->buildings().findRotateInPlaceTarget( + bb.type, center + bb.offset, bb.rotation).has_value()) + { + continue; + } const BuildingDef* def = findBuildingDef(bb.type); if (def) { totalCost += def->cost; } } @@ -491,7 +502,16 @@ void GameWorldView::placeBlueprintAtTile(QPoint center) for (const BlueprintBuilding& bb : bp.buildings) { - const EntityId id = m_sim->tryPlaceBuilding(bb.type, center + bb.offset, bb.rotation); + const QPoint anchor = center + bb.offset; + const std::optional rotateTarget = + m_sim->buildings().findRotateInPlaceTarget(bb.type, anchor, bb.rotation); + if (rotateTarget.has_value()) + { + m_sim->buildings().rotateInPlace(*rotateTarget, bb.rotation); + continue; + } + + const EntityId id = m_sim->tryPlaceBuilding(bb.type, anchor, bb.rotation); if (id == kInvalidEntityId || bb.recipeId.empty()) { continue; } if (bb.type == BuildingType::Shipyard) @@ -511,16 +531,24 @@ void GameWorldView::placeBlueprintAtTile(QPoint center) void GameWorldView::placeAtTile(QPoint tile) { if (!m_builderType.has_value()) - { - return; + { + return; } const BuildingType type = *m_builderType; if (!isValidPlacement(type, tile, m_ghostRotation)) - { - return; + { + return; } + const std::optional rotateTarget = + m_sim->buildings().findRotateInPlaceTarget(type, tile, m_ghostRotation); + if (rotateTarget.has_value()) + { + m_sim->buildings().rotateInPlace(*rotateTarget, m_ghostRotation); + return; + } + if (type == BuildingType::Belt) { if (m_beltDragTiles.count(tile) > 0)