#include "PlacementRules.h" #include "BuildingType.h" #include "FactoryQueries.h" #include "SurfaceMask.h" bool bodyCellsWithinWorldBounds(const FactoryState& state, const GameConfig& config,const std::vector& bodyCells, QPoint anchor) { const int heightTiles = config.world.heightTiles; const int leftEdgeX = -state.asteroidWidth_tiles; for (const QPoint& cell : bodyCells) { const QPoint worldCell = anchor + cell; if (worldCell.y() < 0 || worldCell.y() >= heightTiles) { return false; } if (worldCell.x() < leftEdgeX) { return false; } } return true; } bool isPlacementValid(const FactoryState& state, const GameConfig& config,BuildingType type, QPoint anchor, Rotation rotation) { const BuildingDef* def = config.buildings.findBuildingDef(type); if (def == nullptr) { return false; } const ParsedSurfaceMask mask = parseSurfaceMask(def->surfaceMask, rotation); if (!bodyCellsWithinWorldBounds(state, config, mask.bodyCells, anchor)) { return false; } // Terrain: ship-dock (S) cells must sit in space (x >= 0); all other body // (A) cells must sit on the asteroid (x < 0). (REQ-BLD-PLACE-VALID) for (const QPoint& cell : mask.bodyCells) { const QPoint worldCell = anchor + cell; bool isShipDock = false; for (const QPoint& dock : mask.shipDockCells) { if (dock == cell) { isShipDock = true; break; } } if (isShipDock) { if (worldCell.x() < 0) { return false; } } else if (worldCell.x() >= 0) { return false; } } return true; } std::optional findCoincidingSameTypeBuilding(const FactoryState& state, const GameConfig& config, BuildingType type, QPoint anchor, Rotation rot) { const BuildingDef* def = config.buildings.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 std::optional firstOwner = state.grid.findOwner(firstAbs); if (!firstOwner.has_value()) { return std::nullopt; } const BuildingId candidateId = *firstOwner; for (const QPoint& rel : mask.bodyCells) { const std::optional owner = state.grid.findOwner(anchor + rel); if (!owner.has_value() || *owner != candidateId) { return std::nullopt; } } // Verify the candidate is the same building type with the same cell count. for (const ConstructionSite& site : state.constructionQueue) { if (site.id != candidateId) { continue; } if (site.type != type) { return std::nullopt; } if (site.bodyCells.size() != mask.bodyCells.size()) { return std::nullopt; } return CoincidingBuilding{candidateId, site.rotation}; } for (const Building& b : state.buildings) { if (b.id != candidateId) { continue; } if (b.type != type) { return std::nullopt; } if (b.bodyCells.size() != mask.bodyCells.size()) { return std::nullopt; } return CoincidingBuilding{candidateId, b.rotation}; } return std::nullopt; } std::optional findRotateInPlaceTarget(const FactoryState& state, const GameConfig& config, BuildingType type, QPoint anchor, Rotation rot) { // Tunnel Entries and Tunnel Exits cannot be rotated in place; re-orienting a // tunnel requires deconstructing and re-placing it (REQ-BLD-ROTATE-IN-PLACE). if (type == BuildingType::TunnelEntry || type == BuildingType::TunnelExit) { return std::nullopt; } const std::optional target = findCoincidingSameTypeBuilding(state, config, type, anchor, rot); if (!target.has_value()) { return std::nullopt; } return target->id; } namespace { // The building or construction site occupying `tile`, if it is of `type`, together with // where it stands. The single-building transfer gesture hit-tests the cursor with this // instead of comparing footprints (REQ-UI-BLUEPRINT-TRANSFER). std::optional findSameTypeBuildingAt(const FactoryState& state, BuildingType type, QPoint tile, QPoint& anchorOut) { const std::optional owner = state.grid.findOwner(tile); if (!owner.has_value()) { return std::nullopt; } if (const ConstructionSite* site = findSite(state, *owner)) { if (site->type != type) { return std::nullopt; } anchorOut = site->anchor; return CoincidingBuilding{site->id, site->rotation}; } if (const Building* building = findBuilding(state, *owner)) { if (building->type != type) { return std::nullopt; } anchorOut = building->anchor; return CoincidingBuilding{building->id, building->rotation}; } return std::nullopt; } } // namespace BlueprintGhostResolved resolveBlueprintGhost(const FactoryState& state, const GameConfig& config, BuildingType type, QPoint anchor, Rotation rotation, std::optional hoverTile) { // The single-building gesture first, because it answers without looking at the // ghost's own position at all: whatever same-type building the cursor is on takes the // settings, at any facing, and the ghost snaps onto it (REQ-UI-BLUEPRINT-TRANSFER). if (hoverTile.has_value() && isConfigurableBuildingType(type)) { QPoint hoveredAnchor; const std::optional hovered = findSameTypeBuildingAt(state, type, *hoverTile, hoveredAnchor); if (hovered.has_value()) { return BlueprintGhostResolved{BlueprintGhostAction::Transfer, hovered->id, hoveredAnchor, hovered->rotation}; } } // Terrain and world bounds next: nothing rescues a ghost hanging off the asteroid // (REQ-BLD-PLACE-VALID condition (a)). if (!isPlacementValid(state, config, type, anchor, rotation)) { return BlueprintGhostResolved{BlueprintGhostAction::Invalid, std::nullopt, anchor, rotation}; } const std::optional coinciding = findCoincidingSameTypeBuilding(state, config, type, anchor, rotation); if (coinciding.has_value()) { // The building the blueprint wants is already there, facing the same way. It // takes the blueprint's settings if it has any to take (REQ-UI-BLUEPRINT-TRANSFER) // and is otherwise left exactly as it is (REQ-UI-BLUEPRINT-OVERLAP). Tunnels are // not excluded from the latter: nothing is rotated, so the reason for their // REQ-BLD-ROTATE-IN-PLACE exception does not arise. if (coinciding->rotation == rotation) { return BlueprintGhostResolved{isConfigurableBuildingType(type) ? BlueprintGhostAction::Transfer : BlueprintGhostAction::CompatibleOverlap, coinciding->id, anchor, rotation}; } // Facing the other way, and placement may not re-orient it. return BlueprintGhostResolved{BlueprintGhostAction::Invalid, std::nullopt, anchor, rotation}; } // No coincidence: any occupancy at all is an ordinary overlap // (REQ-BLD-PLACE-VALID condition (b)). const BuildingDef* def = config.buildings.findBuildingDef(type); if (!def) { return BlueprintGhostResolved{BlueprintGhostAction::Invalid, std::nullopt, anchor, rotation}; } const ParsedSurfaceMask parsed = parseSurfaceMask(def->surfaceMask, rotation); for (const QPoint& relativeCell : parsed.bodyCells) { if (isTileOccupied(state, anchor + relativeCell)) { return BlueprintGhostResolved{BlueprintGhostAction::Invalid, std::nullopt, anchor, rotation}; } } return BlueprintGhostResolved{BlueprintGhostAction::PlaceNew, std::nullopt, anchor, rotation}; } 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; }