diff --git a/docs/requirements.md b/docs/requirements.md index 4397ff5..576063e 100644 --- a/docs/requirements.md +++ b/docs/requirements.md @@ -105,7 +105,7 @@ Modules in `modules.toml` define a `surface_mask` — a list of strings that des - REQ-BLD-ROTATE: While in builder mode, pressing Shift+R rotates the ghost 90° clockwise and R rotates it 90° counter-clockwise. Rotation affects the direction of the output port. - REQ-BLD-PLACE: Clicking a valid tile in builder mode places a construction site and adds it to the build queue, consuming building blocks from the global stock. (For belts, placement is instead deferred to a drag gesture and happens on mouse release — REQ-BLD-BELT-DRAG.) - REQ-BLD-PLACE-VALID: A placement position is valid only if (a) every footprint cell in the rotated `surface_mask` is satisfied by the underlying terrain — `A` cells coincide with asteroid tiles, `S` cells coincide with space tiles — (b) no footprint cell overlaps an existing placed building or construction site, except as allowed by REQ-BLD-ROTATE-IN-PLACE, and (c) the player has enough building blocks to afford the building. The ghost (REQ-BLD-GHOST) is rendered in a distinct "invalid" color — overriding its per-building coloring (REQ-BLD-GHOST) — when the current cursor position fails any of these conditions. -- REQ-BLD-ROTATE-IN-PLACE: If the ghost's footprint exactly coincides with the footprint of an existing placed building or construction site of the same building type, clicking places no new construction site and consumes no building blocks. Instead, the existing building or site is rotated to match the ghost's rotation. If the target is a construction site, its construction progress is preserved. This applies in both normal builder mode and blueprint placement mode; in blueprint placement mode it is evaluated per building in the blueprint independently — buildings in the blueprint whose footprint coincides with an existing same-type building or site are rotated in place, while the remaining buildings in the blueprint are placed as normal construction sites (subject to the usual validity checks and total cost). +- REQ-BLD-ROTATE-IN-PLACE: If the ghost's footprint exactly coincides with the footprint of an existing placed building or construction site of the same building type, clicking places no new construction site and consumes no building blocks. Instead, the existing building or site is rotated to match the ghost's rotation. If the target is a construction site, its construction progress is preserved. **Exception:** Tunnel Entries and Tunnel Exits are never rotated in place — re-orienting a tunnel requires demolishing and re-placing it (REQ-BLD-TUNNEL-MODE). A tunnel ghost whose footprint coincides with an existing tunnel is therefore treated as an ordinary occupied-tile placement (invalid in normal builder mode; skipped in blueprint placement mode). This applies in both normal builder mode and blueprint placement mode; in blueprint placement mode it is evaluated per building in the blueprint independently — buildings in the blueprint whose footprint coincides with an existing same-type building or site are rotated in place, while the remaining buildings in the blueprint are placed as normal construction sites (subject to the usual validity checks and total cost). - REQ-BLD-BELT-DRAG: **Belt drag placement.** For belts, placement is a deferred drag gesture rather than immediate per-tile placement: construction sites are not placed while the cursor hovers new tiles, but only once the player releases the left mouse button. Pressing the left mouse button in the game world while in belt builder mode starts a drag anchored at the tile under the cursor. As the cursor moves, a **rectilinear (L-shaped) path** of belt tiles is computed from the anchor tile to the tile under the cursor: the path first runs along the axis **parallel to the belt's current orientation** (REQ-BLD-ROTATE) — stepping toward the cursor's coordinate on that axis to a corner tile — and then runs along the orthogonal axis to the cursor tile. When the cursor shares the anchor's row or column the path degenerates to a straight line, and when it is on the anchor tile the path is a single tile. - **Snapping to a building.** When the tile under the cursor is occupied by a non-belt building or construction site (the **target**), the path does not end on that occupied tile. Instead the end tile is the tile **closest to the cursor** (by distance from the cursor position to the tile) among the tiles orthogonally adjacent to the target across one of its **input-capable edges** — any footprint edge that is not one of the target's output ports, i.e. an edge on which the target can accept an incoming item (REQ-MAT-INPUT-PORTS for buildings, REQ-MAT-ACCEPT-DIR for splitters and tunnels). The geometrically closest such tile is **always** used, even if it turns out not to be a valid belt endpoint — in that case it is previewed and applied by the ordinary rules below (invalid color and skipped if occupied by a non-belt building or invalid terrain; re-oriented if it already holds a belt). The rest of the L-shaped path is computed from the anchor to this end tile exactly as above. The end tile's belt direction points **toward the target** (across the shared input edge), overriding the "final tile keeps its incoming step" rule; this applies whether the end tile is a newly placed belt or an existing belt re-oriented in place, and is reflected both in the ghost preview and in the placement on release. - **Rotating during the drag.** Rotating the belt with R / Shift+R (REQ-BLD-ROTATE) while a drag is in progress re-picks the path's primary axis immediately from the new orientation and re-derives the whole path from the anchor to the current cursor tile, without waiting for the next cursor movement. diff --git a/src/lib/sim/BuildingSystem.cpp b/src/lib/sim/BuildingSystem.cpp index bd9d3c6..3d4732a 100644 --- a/src/lib/sim/BuildingSystem.cpp +++ b/src/lib/sim/BuildingSystem.cpp @@ -1492,6 +1492,13 @@ bool BuildingSystem::isTileOccupied(QPoint tile) const 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 demolishing and re-placing it (REQ-BLD-ROTATE-IN-PLACE). + if (type == BuildingType::TunnelEntry || type == BuildingType::TunnelExit) + { + return std::nullopt; + } + const BuildingDef* def = findBuildingDef(type); if (!def) { return std::nullopt; } diff --git a/src/test/BuildingTest.cpp b/src/test/BuildingTest.cpp index 559b9ff..305c8ab 100644 --- a/src/test/BuildingTest.cpp +++ b/src/test/BuildingTest.cpp @@ -1115,6 +1115,32 @@ TEST_CASE("BuildingSystem: findRotateInPlaceTarget returns nullopt when building bs.findRotateInPlaceTarget(BuildingType::Splitter, QPoint(0, 0), Rotation::East).has_value()); } +TEST_CASE("BuildingSystem: findRotateInPlaceTarget never rotates a tunnel in place", + "[building][rotate-in-place]") +{ + const GameConfig cfg = loadConfig(); + BeltSystem belts(cfg.world.beltSpeed_tps); + int stock = 0; + std::mt19937 rng(0); + BuildingId nextBuildingId = 1; + BuildingSystem bs(cfg, belts, + [&nextBuildingId]() { return nextBuildingId++; }, + [&stock](int n) { stock += n; }, + [](const std::string&, QVector2D, const std::optional&) {}, + [](const std::string&) -> bool { return true; }, + rng); + + // Even with a coincident same-type tunnel under the ghost, rotate-in-place is + // never offered for tunnels (REQ-BLD-ROTATE-IN-PLACE exception). + bs.place(BuildingType::TunnelEntry, QPoint(-1, 0), Rotation::East, 0); + bs.place(BuildingType::TunnelExit, QPoint(-2, 0), Rotation::East, 0); + + REQUIRE_FALSE( + bs.findRotateInPlaceTarget(BuildingType::TunnelEntry, QPoint(-1, 0), Rotation::North).has_value()); + REQUIRE_FALSE( + bs.findRotateInPlaceTarget(BuildingType::TunnelExit, QPoint(-2, 0), Rotation::North).has_value()); +} + TEST_CASE("BuildingSystem: findRotateInPlaceTarget returns nullopt when footprints only partially overlap", "[building][rotate-in-place]") {