From d54bf3587b4693e2b69648d003f76258575d8abc Mon Sep 17 00:00:00 2001 From: Malte Langkabel Date: Wed, 5 Aug 2026 18:55:21 +0200 Subject: [PATCH] move two placement queries out of the view into PlacementRules Prerequisite for the WorldRenderer extraction: both the renderer and the click path need these, so neither can own them. GameWorldView::isValidPlacement was isPlacementValid plus the occupancy and rotate-in-place rule - the gap PlacementRules.h already names in its own comment ("Tile occupancy is NOT checked here"). It becomes canPlaceBuilding there, named for what it adds rather than colliding with isPlacementValid. resolveBeltDragPath was a pure function of the path, the factory state and the config, living in the view only because the view happened to draw the ghosts. It moves next to the other placement rules, taking its BeltTileAction and BeltDragResolved types with it out of the GameWorldView class body. It stays shared for the reason it always was: the previewed ghosts and the placement on release must not disagree about which tiles are affordable. The view keeps two one-line wrappers that bind its own simulation, so call sites still read canPlaceBuildingHere(type, anchor, rotation). Behaviour is unchanged; both are now testable without a widget. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01JcReq7hVk4KUPhTDKWAG7K --- src/lib/sim/PlacementRules.cpp | 79 +++++++++++++++++++++++++++++++ src/lib/sim/PlacementRules.h | 33 +++++++++++++ src/ui/GameWorldView.cpp | 86 +++++----------------------------- src/ui/GameWorldView.h | 15 ++---- 4 files changed, 129 insertions(+), 84 deletions(-) diff --git a/src/lib/sim/PlacementRules.cpp b/src/lib/sim/PlacementRules.cpp index ae6cff6..135d351 100644 --- a/src/lib/sim/PlacementRules.cpp +++ b/src/lib/sim/PlacementRules.cpp @@ -119,3 +119,82 @@ std::optional findRotateInPlaceTarget(const FactoryState& state, con return std::nullopt; } + +bool canPlaceBuilding(const FactoryState& state, const GameConfig& config, + BuildingType type, QPoint anchor, Rotation rotation) +{ + // Terrain and world-bounds validity first (REQ-BLD-PLACE-VALID); occupancy is + // the extra rule this adds. + if (!isPlacementValid(state, config, type, anchor, rotation)) + { + return false; + } + + const BuildingDef* def = config.buildings.findBuildingDef(type); + if (!def) { return false; } + const ParsedSurfaceMask parsed = parseSurfaceMask(def->surfaceMask, rotation); + + bool anyOccupied = false; + for (const QPoint& relativeCell : parsed.bodyCells) + { + if (isTileOccupied(state, anchor + relativeCell)) + { + anyOccupied = true; + break; + } + } + + if (anyOccupied) + { + // Occupied is still placeable when what is there is the same building being + // re-oriented (REQ-BLD-ROTATE-IN-PLACE). + return findRotateInPlaceTarget(state, config, type, anchor, rotation).has_value(); + } + return true; +} + +std::vector resolveBeltDragPath(const std::vector& path, + const FactoryState& state, + const GameConfig& config, + int buildingBlocksStock) +{ + std::vector resolved; + resolved.reserve(path.size()); + + const BuildingDef* def = config.buildings.findBuildingDef(BuildingType::Belt); + const int beltCost = (def != nullptr) ? def->cost : 0; + int spent = 0; + + for (const BeltPathTile& entry : path) + { + BeltDragResolved item; + const std::optional rotateTarget = + findRotateInPlaceTarget(state, config, BuildingType::Belt, + entry.tile, entry.rotation); + if (rotateTarget.has_value()) + { + // A tile holding only a belt (or belt site) is re-oriented, no cost. + item.action = BeltTileAction::RotateInPlace; + item.affordable = true; + item.rotateId = rotateTarget; + } + else if (canPlaceBuilding(state, config, BuildingType::Belt, + entry.tile, entry.rotation)) + { + // Empty, valid cell: a new belt, subject to cumulative affordability. + item.action = BeltTileAction::PlaceNew; + item.affordable = (spent + beltCost <= buildingBlocksStock); + item.rotateId = std::nullopt; + if (item.affordable) { spent += beltCost; } + } + else + { + // Occupied by a non-belt building/site, or otherwise invalid terrain. + item.action = BeltTileAction::Invalid; + item.affordable = false; + item.rotateId = std::nullopt; + } + resolved.push_back(item); + } + return resolved; +} diff --git a/src/lib/sim/PlacementRules.h b/src/lib/sim/PlacementRules.h index 38437ab..8e964e1 100644 --- a/src/lib/sim/PlacementRules.h +++ b/src/lib/sim/PlacementRules.h @@ -5,6 +5,7 @@ #include +#include "BeltDragPath.h" #include "BuildingId.h" #include "BuildingType.h" #include "FactoryState.h" @@ -36,3 +37,35 @@ std::optional findRotateInPlaceTarget(const FactoryState& state, const GameConfig& config, BuildingType type, QPoint anchor, Rotation rot); + +// True if placing here would actually do something: the terrain and bounds rules of +// isPlacementValid hold, and the body cells are either all free or occupied only by +// a building this placement would rotate in place. This is the question the ghost +// asks to colour itself and the click path asks before enqueuing a command +// (REQ-BLD-GHOST, REQ-BLD-PLACE-VALID, REQ-BLD-ROTATE-IN-PLACE). +bool canPlaceBuilding(const FactoryState& state, const GameConfig& config, + BuildingType type, QPoint anchor, Rotation rotation); + +// What a belt drag would do to one tile of its path (REQ-BLD-BELT-DRAG). +enum class BeltTileAction +{ + PlaceNew, // empty, valid cell: a new belt, subject to affordability + RotateInPlace, // already a belt (or belt site): re-oriented, free + Invalid // occupied by something else, or invalid terrain +}; + +struct BeltDragResolved +{ + BeltTileAction action; + bool affordable; // meaningful only for PlaceNew + std::optional rotateId; // set only for RotateInPlace +}; + +// Classifies every tile of a belt drag path against the current factory state, +// spending `buildingBlocksStock` cumulatively across the PlaceNew tiles so a path +// longer than the player can afford is only partly buildable (REQ-BLD-BELT-DRAG). +// Shared so the previewed ghosts and the placement on release cannot disagree. +std::vector resolveBeltDragPath(const std::vector& path, + const FactoryState& state, + const GameConfig& config, + int buildingBlocksStock); diff --git a/src/ui/GameWorldView.cpp b/src/ui/GameWorldView.cpp index 3f05903..c74f68d 100644 --- a/src/ui/GameWorldView.cpp +++ b/src/ui/GameWorldView.cpp @@ -563,36 +563,11 @@ ScrollBounds GameWorldView::getScrollBounds() const // Placement helpers // --------------------------------------------------------------------------- -bool GameWorldView::isValidPlacement(BuildingType type, QPoint anchor, - Rotation rot) const +bool GameWorldView::canPlaceBuildingHere(BuildingType type, QPoint anchor, + Rotation rot) const { - // 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 (!isPlacementValid(m_sim->getFactoryState(), m_sim->getConfig(), type, anchor, rot)) - { - return false; - } - - const BuildingDef* def = m_config->buildings.findBuildingDef(type); - if (!def) { return false; } - const ParsedSurfaceMask parsed = parseSurfaceMask(def->surfaceMask, rot); - - bool anyOccupied = false; - for (const QPoint& relCell : parsed.bodyCells) - { - if (isTileOccupied(m_sim->getFactoryState(), anchor + relCell)) - { - anyOccupied = true; - break; - } - } - - if (anyOccupied) - { - return findRotateInPlaceTarget(m_sim->getFactoryState(), m_sim->getConfig(), type, anchor, rot).has_value(); - } - return true; + return canPlaceBuilding(m_sim->getFactoryState(), m_sim->getConfig(), + type, anchor, rot); } std::optional GameWorldView::buildingAtTile(QPoint tile) const @@ -736,7 +711,7 @@ void GameWorldView::placeBlueprintAtTile(QPoint center) // Locked building types are excluded from this placement entirely // (REQ-LOCK-BUILDING, REQ-LOCK-UI-BLUEPRINT): not validity-checked here. if (!m_sim->isBuildingUnlocked(bb.type)) { continue; } - if (!isValidPlacement(bb.type, center + bb.offset, bb.rotation)) { return; } + if (!canPlaceBuildingHere(bb.type, center + bb.offset, bb.rotation)) { return; } } // Cost only applies to buildings that are genuinely new (not rotate-in-place), @@ -878,7 +853,7 @@ void GameWorldView::placeAtTile(QPoint tile) const BuildingType type = m_buildMode.getEffectiveBuilderType(); const Rotation rotation = m_buildMode.getGhostRotation(); - if (!isValidPlacement(type, tile, rotation)) + if (!canPlaceBuildingHere(type, tile, rotation)) { return; } @@ -980,46 +955,11 @@ void GameWorldView::recomputeBeltDragPath(QPoint cursorTile) m_buildMode.setBeltDragPath(std::move(path)); } -std::vector GameWorldView::resolveBeltDragPath() const +std::vector GameWorldView::resolveBeltDragPath() const { - std::vector resolved; - resolved.reserve(m_buildMode.getBeltDragPath().size()); - - const BuildingDef* def = m_config->buildings.findBuildingDef(BuildingType::Belt); - const int beltCost = (def != nullptr) ? def->cost : 0; - const int stock = m_sim->getBuildingBlocksStock(); - int spent = 0; - - for (const BeltPathTile& entry : m_buildMode.getBeltDragPath()) - { - BeltDragResolved item; - const std::optional rotateTarget = - 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. - item.action = BeltTileAction::RotateInPlace; - item.affordable = true; - item.rotateId = rotateTarget; - } - else if (isValidPlacement(BuildingType::Belt, entry.tile, entry.rotation)) - { - // Empty, valid cell: a new belt, subject to cumulative affordability. - item.action = BeltTileAction::PlaceNew; - item.affordable = (spent + beltCost <= stock); - item.rotateId = std::nullopt; - if (item.affordable) { spent += beltCost; } - } - else - { - // Occupied by a non-belt building/site, or otherwise invalid terrain. - item.action = BeltTileAction::Invalid; - item.affordable = false; - item.rotateId = std::nullopt; - } - resolved.push_back(item); - } - return resolved; + return ::resolveBeltDragPath(m_buildMode.getBeltDragPath(), + m_sim->getFactoryState(), m_sim->getConfig(), + m_sim->getBuildingBlocksStock()); } void GameWorldView::applyBeltDragPath() @@ -1947,7 +1887,7 @@ void GameWorldView::drawOverlays(QPainter& painter, const WorldCoordinates& coor // REQ-LOCK-UI-BLUEPRINT), so they are not ghosted either. if (!m_sim->isBuildingUnlocked(bb.type)) { continue; } const QPoint anchor = m_buildMode.getBlueprintGhostTile() + bb.offset; - const bool valid = isValidPlacement(bb.type, anchor, bb.rotation); + const bool valid = canPlaceBuildingHere(bb.type, anchor, bb.rotation); drawBuildingGhost(painter, coordinates, bb.type, anchor, bb.rotation, valid, /*showPortTargetGlyphs*/ false); } @@ -2437,7 +2377,7 @@ void GameWorldView::mouseMoveEvent(QMouseEvent* event) { m_buildMode.setGhostTile(tile); m_buildMode.setGhostValidity( - isValidPlacement(m_buildMode.getBuilderType(), tile, + canPlaceBuildingHere(m_buildMode.getBuilderType(), tile, m_buildMode.getGhostRotation())); if (m_buildMode.isTunnelMode()) @@ -2564,7 +2504,7 @@ void GameWorldView::rotateGhost(bool clockwise) { m_buildMode.rotateGhost(clockwise); m_buildMode.setGhostValidity( - isValidPlacement(m_buildMode.getBuilderType(), m_buildMode.getGhostTile(), + canPlaceBuildingHere(m_buildMode.getBuilderType(), m_buildMode.getGhostTile(), m_buildMode.getGhostRotation())); // A new facing changes which tunnels the ghost could complete (REQ-BLD-TUNNEL-MODE). if (m_buildMode.isTunnelMode()) { updateTunnelGhost(); } diff --git a/src/ui/GameWorldView.h b/src/ui/GameWorldView.h index 79486e0..88b8f12 100644 --- a/src/ui/GameWorldView.h +++ b/src/ui/GameWorldView.h @@ -52,6 +52,7 @@ #include "CommandManager.h" #include "EntitySelectionChangedEvent.h" #include "GameConfig.h" +#include "PlacementRules.h" #include "Rotation.h" #include "SelectionController.h" #include "Tick.h" @@ -213,7 +214,8 @@ private: // both edges move with asteroid expansion and with pushes (REQ-GW-SCROLL-LIMIT). ScrollBounds getScrollBounds() const; - bool isValidPlacement(BuildingType type, QPoint anchor, Rotation rot) const; + // canPlaceBuilding (PlacementRules) against this view's simulation. + bool canPlaceBuildingHere(BuildingType type, QPoint anchor, Rotation rot) const; std::optional buildingAtTile(QPoint tile) const; std::optional siteAtTile(QPoint tile) const; // Ids of all buildings and construction sites whose footprint intersects @@ -276,19 +278,10 @@ private: const WorldCoordinates& coordinates); // Belt drag placement (REQ-BLD-BELT-DRAG). - // Per-path-tile decision, shared by ghost drawing and release-time placement. - enum class BeltTileAction { PlaceNew, RotateInPlace, Invalid }; - struct BeltDragResolved - { - BeltTileAction action; - bool affordable; // meaningful only for PlaceNew - std::optional rotateId; // set only for RotateInPlace - }; // Recomputes the drag path from its anchor to cursorTile using the current ghost // orientation, and stores it on the build mode controller. void recomputeBeltDragPath(QPoint cursorTile); - // Classifies each path tile against the current sim state, applying cumulative - // affordability to the PlaceNew tiles. + // resolveBeltDragPath (PlacementRules) against this view's simulation. std::vector resolveBeltDragPath() const; // Enqueues placements and rotate-in-place commands for the resolved path. void applyBeltDragPath();