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();