2 Commits

Author SHA1 Message Date
b3d6264ed3 move the placement rules and the config-dependent queries off BuildingSystem
isPlacementValid, findRotateInPlaceTarget and the bodyCellsWithinWorldBounds
helper become PlacementRules.h — where a building may go and what already sits
on those tiles, answered from the factory state and the config. getInputPorts
and getSiteSplitterInfo join FactoryQueries.h, whose header comment now says
plainly that the last two also take the config because answering them means
reading a building definition.

computeInputPorts goes to PortGeometry.h alongside outputBodyTile/inputBodyTile:
it needs only Port and QPoint, so it belongs in core rather than in sim.

BuildingSystem is left with no query that reads the factory — its remaining const
methods are the emerging/incoming item walks, the checksum fold, and the buffer
initialisers. It changes the factory now; it no longer describes it.

Verified with a golden-checksum capture before and after — all four sample ticks
identical.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YHcUerKAZKWNvSKJxYKbnG
2026-08-04 22:31:16 +02:00
ade716edf2 delete the unused getAllBeltTiles and BeltTileInfo
Nothing in lib, ui, balancing or the tests calls it — the only references were
its own declaration and definition.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YHcUerKAZKWNvSKJxYKbnG
2026-08-04 22:23:34 +02:00
14 changed files with 340 additions and 308 deletions

View File

@@ -20,6 +20,7 @@ SET(HDRS
SET(SRCS
${SRCS}
${CMAKE_CURRENT_SOURCE_DIR}/BuildingType.cpp
${CMAKE_CURRENT_SOURCE_DIR}/PortGeometry.cpp
${CMAKE_CURRENT_SOURCE_DIR}/EntityAdmin.cpp
${CMAKE_CURRENT_SOURCE_DIR}/DisplayName.cpp
${CMAKE_CURRENT_SOURCE_DIR}/BeltDragPath.cpp

View File

@@ -0,0 +1,57 @@
#include "PortGeometry.h"
#include <set>
#include <utility>
std::vector<Port> computeInputPorts(
const std::vector<QPoint>& bodyCells,
const std::vector<Port>& outputPorts)
{
// Build lookup sets for quick membership checks.
std::set<std::pair<int, int>> bodySet;
for (const QPoint& cell : bodyCells)
{
bodySet.insert({cell.x(), cell.y()});
}
std::set<std::pair<int, int>> outputPortTiles;
for (const Port& port : outputPorts)
{
outputPortTiles.insert({port.tile.x(), port.tile.y()});
}
// Neighbour deltas and the corresponding "inward" belt direction.
const int dx[4] = {-1, 1, 0, 0};
const int dy[4] = { 0, 0, -1, 1};
const Rotation inward[4] = {
Rotation::East, // neighbour is to the West; belt flows East toward building
Rotation::West, // neighbour is to the East; belt flows West toward building
Rotation::South, // neighbour is above (row-1); belt flows South toward building
Rotation::North // neighbour is below (row+1); belt flows North toward building
};
std::set<std::pair<int, int>> seen;
std::vector<Port> inputPorts;
for (const QPoint& cell : bodyCells)
{
for (int i = 0; i < 4; ++i)
{
const int nx = cell.x() + dx[i];
const int ny = cell.y() + dy[i];
const std::pair<int, int> neighbor = {nx, ny};
if (bodySet.count(neighbor)) { continue; }
if (outputPortTiles.count(neighbor)){ continue; }
if (seen.count(neighbor)) { continue; }
seen.insert(neighbor);
Port port;
port.tile = QPoint(nx, ny);
port.direction = inward[i];
inputPorts.push_back(port);
}
}
return inputPorts;
}

View File

@@ -1,7 +1,10 @@
#pragma once
#include <vector>
#include <QPoint>
#include "Port.h"
#include "Rotation.h"
// Geometry of a building's input/output ports. A Port names the tile *outside* the
@@ -43,3 +46,10 @@ inline QPoint inputBodyTile(QPoint portTile, Rotation inwardDirection)
}
return portTile;
}
// Every belt-facing edge of a footprint that is not already an output port — the
// tiles a belt can feed the building from, with the direction items must flow to
// enter (REQ-MAT-INPUT-PORTS, REQ-BLD-BELT-DRAG). bodyCells and outputPorts are
// in absolute tile coordinates, and so is the result.
std::vector<Port> computeInputPorts(const std::vector<QPoint>& bodyCells,
const std::vector<Port>& outputPorts);

View File

@@ -7,6 +7,7 @@
#include <set>
#include "FactoryQueries.h"
#include "PlacementRules.h"
#include "ProductionRules.h"
#include "PortGeometry.h"
#include "StateChecksum.h"
@@ -182,87 +183,6 @@ void BuildingSystem::initSalvageBayBuffer(Building& b) const
(def && def->outputBufferCapacity) ? *def->outputBufferCapacity : 0;
}
std::vector<Port> BuildingSystem::computeInputPorts(const Building& b) const
{
return computeInputPorts(b.bodyCells, b.outputPorts);
}
std::vector<Port> BuildingSystem::computeInputPorts(
const std::vector<QPoint>& bodyCells,
const std::vector<Port>& outputPorts) const
{
// Build lookup sets for quick membership checks.
std::set<std::pair<int, int>> bodySet;
for (const QPoint& cell : bodyCells)
{
bodySet.insert({cell.x(), cell.y()});
}
std::set<std::pair<int, int>> outputPortTiles;
for (const Port& port : outputPorts)
{
outputPortTiles.insert({port.tile.x(), port.tile.y()});
}
// Neighbour deltas and the corresponding "inward" belt direction.
const int dx[4] = {-1, 1, 0, 0};
const int dy[4] = { 0, 0, -1, 1};
const Rotation inward[4] = {
Rotation::East, // neighbour is to the West; belt flows East toward building
Rotation::West, // neighbour is to the East; belt flows West toward building
Rotation::South, // neighbour is above (row-1); belt flows South toward building
Rotation::North // neighbour is below (row+1); belt flows North toward building
};
std::set<std::pair<int, int>> seen;
std::vector<Port> inputPorts;
for (const QPoint& cell : bodyCells)
{
for (int i = 0; i < 4; ++i)
{
const int nx = cell.x() + dx[i];
const int ny = cell.y() + dy[i];
const std::pair<int, int> neighbor = {nx, ny};
if (bodySet.count(neighbor)) { continue; }
if (outputPortTiles.count(neighbor)){ continue; }
if (seen.count(neighbor)) { continue; }
seen.insert(neighbor);
Port port;
port.tile = QPoint(nx, ny);
port.direction = inward[i];
inputPorts.push_back(port);
}
}
return inputPorts;
}
std::vector<Port> BuildingSystem::getInputPorts(BuildingId id) const
{
if (const Building* building = findBuilding(m_state, id))
{
return building->inputPorts;
}
if (const ConstructionSite* site = findSite(m_state, id))
{
// A site stores no ports; derive its output ports from the mask (absolute)
// and run the same input-edge scan (REQ-BLD-BELT-DRAG, REQ-MAT-INPUT-PORTS).
const BuildingDef* def = m_config.buildings.findBuildingDef(site->type);
if (def == nullptr) { return {}; }
const ParsedSurfaceMask mask = parseSurfaceMask(def->surfaceMask, site->rotation);
std::vector<Port> outputPortsAbsolute;
outputPortsAbsolute.reserve(mask.outputPorts.size());
for (const Port& port : mask.outputPorts)
{
outputPortsAbsolute.push_back(Port{ site->anchor + port.tile, port.direction });
}
return computeInputPorts(site->bodyCells, outputPortsAbsolute);
}
return {};
}
std::vector<Item> BuildingSystem::rollReprocessingOutput(const RecipeDef& recipe)
{
@@ -301,7 +221,7 @@ std::optional<BuildingId> BuildingSystem::place(BuildingType type, QPoint anchor
const ParsedSurfaceMask mask = parseSurfaceMask(def->surfaceMask, rotation);
// Reject placements that fall outside the world (REQ-BLD-PLACE-VALID).
if (!bodyCellsWithinWorldBounds(mask.bodyCells, anchor))
if (!bodyCellsWithinWorldBounds(m_state, m_config, mask.bodyCells, anchor))
{
return std::nullopt;
}
@@ -337,70 +257,6 @@ std::optional<BuildingId> BuildingSystem::place(BuildingType type, QPoint anchor
return id;
}
bool BuildingSystem::bodyCellsWithinWorldBounds(const std::vector<QPoint>& bodyCells,
QPoint anchor) const
{
const int heightTiles = m_config.world.heightTiles;
const int leftEdgeX = -m_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 BuildingSystem::isPlacementValid(BuildingType type, QPoint anchor,
Rotation rotation) const
{
const BuildingDef* def = m_config.buildings.findBuildingDef(type);
if (def == nullptr)
{
return false;
}
const ParsedSurfaceMask mask = parseSurfaceMask(def->surfaceMask, rotation);
if (!bodyCellsWithinWorldBounds(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;
}
// ---------------------------------------------------------------------------
// Deconstruct
// ---------------------------------------------------------------------------
@@ -595,29 +451,6 @@ void BuildingSystem::setShipLayout(BuildingId id, const ShipLayoutConfig& layout
}
}
std::optional<BeltSystem::SplitterInfo>
BuildingSystem::getSiteSplitterInfo(BuildingId id) const
{
for (const ConstructionSite& site : m_state.constructionQueue)
{
if (site.id != id) { continue; }
if (site.type != BuildingType::Splitter) { return std::nullopt; }
const BuildingDef* def = m_config.buildings.findBuildingDef(site.type);
const ParsedSurfaceMask mask = parseSurfaceMask(
def ? def->surfaceMask : std::vector<std::string>{}, site.rotation);
if (mask.outputPorts.size() < 2) { return std::nullopt; }
BeltSystem::SplitterInfo info;
info.outputA = mask.outputPorts[0].direction;
info.outputB = mask.outputPorts[1].direction;
info.filterA = site.splitterFilterA;
info.filterB = site.splitterFilterB;
return info;
}
return std::nullopt;
}
void BuildingSystem::setSiteSplitterFilters(BuildingId id,
const std::vector<ItemType>& filterA,
const std::vector<ItemType>& filterB)
@@ -690,7 +523,7 @@ void BuildingSystem::tickConstruction(Tick currentTick)
building.outputPorts.push_back(absPort);
}
building.emergingItems.resize(building.outputPorts.size());
building.inputPorts = computeInputPorts(building);
building.inputPorts = computeInputPorts(building.bodyCells, building.outputPorts);
building.incomingItems.assign(building.inputPorts.size(), {});
if (building.type == BuildingType::SalvageBay)
@@ -1263,88 +1096,6 @@ void BuildingSystem::forEachIncomingItem(
std::vector<BuildingSystem::BeltTileInfo> BuildingSystem::getAllBeltTiles() const
{
std::vector<BeltTileInfo> result;
for (const Building& b : m_state.buildings)
{
if (b.type != BuildingType::Belt && b.type != BuildingType::Splitter)
{
continue;
}
BeltTileInfo info;
info.buildingId = b.id;
info.tile = b.bodyCells.empty() ? b.anchor : b.bodyCells[0];
info.type = b.type;
if (!b.outputPorts.empty())
{
info.directionA = b.outputPorts[0].direction;
info.directionB = b.outputPorts[0].direction;
}
else
{
info.directionA = b.rotation;
info.directionB = b.rotation;
}
if (b.type == BuildingType::Splitter && b.outputPorts.size() >= 2)
{
info.directionB = b.outputPorts[1].direction;
}
result.push_back(info);
}
return result;
}
std::optional<BuildingId> BuildingSystem::findRotateInPlaceTarget(
BuildingType type, QPoint anchor, Rotation rot) const
{
// 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 BuildingDef* def = m_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<BuildingId> firstOwner = m_state.grid.findOwner(firstAbs);
if (!firstOwner.has_value()) { return std::nullopt; }
const BuildingId candidateId = *firstOwner;
for (const QPoint& rel : mask.bodyCells)
{
const std::optional<BuildingId> owner = m_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 : m_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 candidateId;
}
for (const Building& b : m_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 candidateId;
}
return std::nullopt;
}
void BuildingSystem::rotateInPlace(BuildingId id, Rotation newRotation)
{
// Construction site path — just update rotation; no ports to recompute.
@@ -1380,7 +1131,7 @@ void BuildingSystem::rotateInPlace(BuildingId id, Rotation newRotation)
// the lanes to the new port set (REQ-MAT-OUTPUT-EMERGE).
b.emergingItems.clear();
b.emergingItems.resize(b.outputPorts.size());
b.inputPorts = computeInputPorts(b);
b.inputPorts = computeInputPorts(b.bodyCells, b.outputPorts);
// Likewise discard in-transit input items and re-size the input belts to
// the new port set (REQ-MAT-INPUT-INTAKE).
b.incomingItems.assign(b.inputPorts.size(), {});
@@ -1438,7 +1189,7 @@ BuildingId BuildingSystem::placeImmediate(BuildingType type,
building.outputPorts.push_back(absPort);
}
building.emergingItems.resize(building.outputPorts.size());
building.inputPorts = computeInputPorts(building);
building.inputPorts = computeInputPorts(building.bodyCells, building.outputPorts);
building.incomingItems.assign(building.inputPorts.size(), {});
if (type == BuildingType::SalvageBay)

View File

@@ -16,6 +16,7 @@
#include "BeltSystem.h"
#include "Building.h"
#include "FactoryState.h"
#include "PlacementRules.h"
#include "ProductionRules.h"
#include "BuildingType.h"
#include "BuildingId.h"
@@ -60,8 +61,6 @@ public:
// other body (A) cell sits on the asteroid (x < 0 and x >= the left edge),
// and every cell has 0 <= y < world.height_tiles. There is no right-side
// bound — space extends rightward. Tile occupancy is NOT checked here.
bool isPlacementValid(BuildingType type, QPoint anchor,
Rotation rotation) const;
// Sets the current buildable asteroid width in tiles. Grows the left
// placement bound as the player unlocks asteroid expansions (REQ-EXP-UNLOCK).
@@ -99,7 +98,6 @@ public:
// output directions (derived from its surface mask) and stored filters, or
// nullopt if the id is not a Splitter site. The stored filters are applied
// to BeltSystem when the splitter finishes building (tickConstruction).
std::optional<BeltSystem::SplitterInfo> getSiteSplitterInfo(BuildingId id) const;
void setSiteSplitterFilters(BuildingId id,
const std::vector<ItemType>& filterA,
const std::vector<ItemType>& filterB);
@@ -119,14 +117,6 @@ public:
void tickOutputBelts();
// -- Queries -------------------------------------------------------------
struct BeltTileInfo
{
BuildingId buildingId;
QPoint tile;
BuildingType type; // Belt or Splitter
Rotation directionA; // Belt: its direction; Splitter: first output
Rotation directionB; // Splitter: second output; Belt: same as directionA
};
// REQ-UI-DEBUG-OVERLAY "Max Factory Production": count of completed
@@ -139,7 +129,6 @@ public:
// nullopt for building types that show no light (belts, splitters, tunnels,
// HQ, defence stations). The Salvage Bay is a two-state special case:
// Producing while its output buffer holds scrap, Starved when empty.
std::vector<BeltTileInfo> getAllBeltTiles() const;
// Visits every item currently emerging from a building output port on its
// virtual output belt (REQ-MAT-OUTPUT-EMERGE), passing the item type and its
@@ -171,7 +160,6 @@ public:
// (REQ-BLD-BELT-DRAG, REQ-MAT-INPUT-PORTS): each returned Port.tile is the
// outside adjacent tile and Port.direction is the belt facing that points into
// the target. Output-port edges are excluded. Empty for an unknown id.
std::vector<Port> getInputPorts(BuildingId id) const;
// Register / unregister tile occupancy for ECS station entities.
void registerTileOccupancy(const std::vector<QPoint>& cells, BuildingId ownerPlaceholder);
@@ -247,14 +235,8 @@ private:
void initAutoBuffers(Building& b) const;
void initShipyardBuffers(Building& b) const;
void initSalvageBayBuffer(Building& b) const;
std::vector<Port> computeInputPorts(const Building& b) const;
// Core input-edge scan shared by operational buildings and construction sites.
std::vector<Port> computeInputPorts(const std::vector<QPoint>& bodyCells,
const std::vector<Port>& outputPorts) const;
std::vector<Item> rollReprocessingOutput(const RecipeDef& recipe);
bool bodyCellsWithinWorldBounds(
const std::vector<QPoint>& bodyCells,
QPoint anchor) const;
const GameConfig& m_config;

View File

@@ -16,6 +16,7 @@ SET(HDRS
${CMAKE_CURRENT_SOURCE_DIR}/FactoryState.h
${CMAKE_CURRENT_SOURCE_DIR}/FactoryQueries.h
${CMAKE_CURRENT_SOURCE_DIR}/ProductionRules.h
${CMAKE_CURRENT_SOURCE_DIR}/PlacementRules.h
${CMAKE_CURRENT_SOURCE_DIR}/BuildingSystem.h
${CMAKE_CURRENT_SOURCE_DIR}/EntityHitTest.h
${CMAKE_CURRENT_SOURCE_DIR}/ShipLayout.h
@@ -43,6 +44,7 @@ SET(SRCS
${CMAKE_CURRENT_SOURCE_DIR}/BuildingGrid.cpp
${CMAKE_CURRENT_SOURCE_DIR}/FactoryQueries.cpp
${CMAKE_CURRENT_SOURCE_DIR}/ProductionRules.cpp
${CMAKE_CURRENT_SOURCE_DIR}/PlacementRules.cpp
${CMAKE_CURRENT_SOURCE_DIR}/BuildingSystem.cpp
${CMAKE_CURRENT_SOURCE_DIR}/EntityHitTest.cpp
${CMAKE_CURRENT_SOURCE_DIR}/ShipStatsCalculator.cpp

View File

@@ -2,6 +2,9 @@
#include <limits>
#include "PortGeometry.h"
#include "SurfaceMask.h"
#include "Item.h"
#include "ItemType.h"
@@ -126,3 +129,53 @@ bool deliverScrapToSalvageBay(FactoryState& state, BuildingId bayId)
bay->outputBuffer.items.push_back(Item{ItemType{"scrap"}});
return true;
}
std::vector<Port> getInputPorts(const FactoryState& state, const GameConfig& config,
BuildingId id)
{
if (const Building* building = findBuilding(state, id))
{
return building->inputPorts;
}
if (const ConstructionSite* site = findSite(state, id))
{
// A site stores no ports; derive its output ports from the mask (absolute)
// and run the same input-edge scan (REQ-BLD-BELT-DRAG, REQ-MAT-INPUT-PORTS).
const BuildingDef* def = config.buildings.findBuildingDef(site->type);
if (def == nullptr) { return {}; }
const ParsedSurfaceMask mask = parseSurfaceMask(def->surfaceMask, site->rotation);
std::vector<Port> outputPortsAbsolute;
outputPortsAbsolute.reserve(mask.outputPorts.size());
for (const Port& port : mask.outputPorts)
{
outputPortsAbsolute.push_back(Port{ site->anchor + port.tile, port.direction });
}
return computeInputPorts(site->bodyCells, outputPortsAbsolute);
}
return {};
}
std::optional<BeltSystem::SplitterInfo>
getSiteSplitterInfo(const FactoryState& state, const GameConfig& config, BuildingId id)
{
for (const ConstructionSite& site : state.constructionQueue)
{
if (site.id != id) { continue; }
if (site.type != BuildingType::Splitter) { return std::nullopt; }
const BuildingDef* def = config.buildings.findBuildingDef(site.type);
const ParsedSurfaceMask mask = parseSurfaceMask(
def ? def->surfaceMask : std::vector<std::string>{}, site.rotation);
if (mask.outputPorts.size() < 2) { return std::nullopt; }
BeltSystem::SplitterInfo info;
info.outputA = mask.outputPorts[0].direction;
info.outputB = mask.outputPorts[1].direction;
info.filterA = site.splitterFilterA;
info.filterB = site.splitterFilterB;
return info;
}
return std::nullopt;
}

View File

@@ -8,15 +8,19 @@
#include "Building.h"
#include "BuildingId.h"
#include "BuildingType.h"
#include "BeltSystem.h"
#include "FactoryState.h"
#include "GameConfig.h"
#include "Port.h"
// Queries and operations over the factory's world data that need nothing but that
// data — no config, no belts, no RNG. Free functions rather than BuildingSystem
// methods so that callers depend on the data they read instead of on the system
// that happens to tick it (see FactoryState.h).
//
// A helper belongs here only if it is a pure function of FactoryState. Anything
// needing GameConfig or the asteroid bound stays on BuildingSystem for now.
// Most need nothing but the state. The two at the bottom also take the config,
// because answering them means reading a building definition — but still no belts,
// no RNG and no system.
// The building with the given id, or nullptr when no building has it. Construction
// sites are not buildings yet — use findSite for those.
@@ -52,3 +56,16 @@ const Building* findNearestBuilding(const FactoryState& state, QVector2D worldPo
// (REQ-BLD-DECON-QUEUE), or its holding capacity is already taken — emerging
// scrap counts against that capacity (REQ-MAT-OUTPUT-EMERGE).
bool deliverScrapToSalvageBay(FactoryState& state, BuildingId bayId);
// Every belt-facing edge of the building or site with this id (REQ-MAT-INPUT-PORTS,
// REQ-BLD-BELT-DRAG). Empty when the id is unknown. A site has no stored ports, so
// they are derived from its surface mask.
std::vector<Port> getInputPorts(const FactoryState& state, const GameConfig& config,
BuildingId id);
// The two output directions and stored filters of a queued Splitter site
// (REQ-BLD-SITE-CONFIG), or nullopt if the id is not one. Operational splitters are
// configured through BeltSystem by tile instead.
std::optional<BeltSystem::SplitterInfo> getSiteSplitterInfo(const FactoryState& state,
const GameConfig& config,
BuildingId id);

View File

@@ -0,0 +1,121 @@
#include "PlacementRules.h"
#include "BuildingType.h"
#include "FactoryQueries.h"
#include "SurfaceMask.h"
bool bodyCellsWithinWorldBounds(const FactoryState& state, const GameConfig& config,const std::vector<QPoint>& 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<BuildingId> 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 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<BuildingId> 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<BuildingId> 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 candidateId;
}
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 candidateId;
}
return std::nullopt;
}

View File

@@ -0,0 +1,38 @@
#pragma once
#include <optional>
#include <vector>
#include <QPoint>
#include "BuildingId.h"
#include "BuildingType.h"
#include "FactoryState.h"
#include "GameConfig.h"
#include "Rotation.h"
// Where a building may be placed, and what is already sitting on those tiles.
// Free functions over the factory state and the config — they read no other
// system state, so they do not belong to BuildingSystem.
// True if every body cell lies inside the world: 0 <= y < world.height_tiles and
// x >= the current asteroid left edge (REQ-BLD-PLACE-VALID). Terrain type is not
// checked — see isPlacementValid for the full rule.
bool bodyCellsWithinWorldBounds(const FactoryState& state, const GameConfig& config,
const std::vector<QPoint>& bodyCells, QPoint anchor);
// True if the placement satisfies REQ-BLD-PLACE-VALID terrain and world-bounds
// rules: every ship-dock (S) cell sits in space (x >= 0), every other body (A)
// cell sits on the asteroid (x < 0 and x >= the left edge), and every cell has
// 0 <= y < world.height_tiles. There is no right-side bound — space extends
// rightward. Tile occupancy is NOT checked here.
bool isPlacementValid(const FactoryState& state, const GameConfig& config,
BuildingType type, QPoint anchor, Rotation rotation);
// The building or site that a ghost of the given type/anchor/rotation would
// rotate in place rather than replace: same type, same body cells, one owner
// (REQ-BLD-ROTATE-IN-PLACE). Tunnels never qualify.
std::optional<BuildingId> findRotateInPlaceTarget(const FactoryState& state,
const GameConfig& config,
BuildingType type, QPoint anchor,
Rotation rot);

View File

@@ -1,6 +1,7 @@
#include "Simulation.h"
#include "FactoryQueries.h"
#include "PlacementRules.h"
#include <algorithm>
#include <cassert>
@@ -860,7 +861,7 @@ std::optional<BuildingId> Simulation::tryPlaceBuilding(BuildingType type, QPoint
return std::nullopt;
}
if (!m_buildingSystem->isPlacementValid(type, anchor, rotation))
if (!isPlacementValid(m_factoryState, m_config, type, anchor, rotation))
{
return std::nullopt;
}

View File

@@ -1,4 +1,5 @@
#include "catch.hpp"
#include "PlacementRules.h"
#include "FactoryQueries.h"
#include "ProductionRules.h"
@@ -201,19 +202,18 @@ TEST_CASE("BuildingSystem: isPlacementValid enforces terrain and world bounds",
const int leftEdgeX = -f.cfg.world.regions.asteroidWidth_tiles;
// Miner is all-asteroid (A): valid only fully on the asteroid (x < 0).
REQUIRE(f.bs.isPlacementValid(BuildingType::Miner, QPoint(-3, 0), Rotation::East));
REQUIRE_FALSE(f.bs.isPlacementValid(BuildingType::Miner, QPoint(0, 0), Rotation::East)); // A cells in space
REQUIRE_FALSE(f.bs.isPlacementValid(BuildingType::Miner, QPoint(0, -1), Rotation::East)); // above world
REQUIRE(f.bs.isPlacementValid(BuildingType::Miner, QPoint(leftEdgeX, 0), Rotation::East));
REQUIRE_FALSE(f.bs.isPlacementValid(BuildingType::Miner,
QPoint(leftEdgeX - 1, 0), Rotation::East)); // past left edge
REQUIRE(isPlacementValid(f.state, f.cfg, BuildingType::Miner, QPoint(-3, 0), Rotation::East));
REQUIRE_FALSE(isPlacementValid(f.state, f.cfg, BuildingType::Miner, QPoint(0, 0), Rotation::East)); // A cells in space
REQUIRE_FALSE(isPlacementValid(f.state, f.cfg, BuildingType::Miner, QPoint(0, -1), Rotation::East)); // above world
REQUIRE(isPlacementValid(f.state, f.cfg, BuildingType::Miner, QPoint(leftEdgeX, 0), Rotation::East));
REQUIRE_FALSE(isPlacementValid(f.state, f.cfg, BuildingType::Miner, QPoint(leftEdgeX - 1, 0), Rotation::East)); // past left edge
// Shipyard mask ["AAAS>","AAAS "] straddles the boundary: A cells on the
// asteroid, the S (dock) cell in space. At anchor (-3,0) the A cells land at
// x=-3..-1 and the dock at x=0.
REQUIRE(f.bs.isPlacementValid(BuildingType::Shipyard, QPoint(-3, 0), Rotation::East));
REQUIRE_FALSE(f.bs.isPlacementValid(BuildingType::Shipyard, QPoint(0, 0), Rotation::East)); // A cells in space
REQUIRE_FALSE(f.bs.isPlacementValid(BuildingType::Shipyard, QPoint(-4, 0), Rotation::East)); // dock on asteroid
REQUIRE(isPlacementValid(f.state, f.cfg, BuildingType::Shipyard, QPoint(-3, 0), Rotation::East));
REQUIRE_FALSE(isPlacementValid(f.state, f.cfg, BuildingType::Shipyard, QPoint(0, 0), Rotation::East)); // A cells in space
REQUIRE_FALSE(isPlacementValid(f.state, f.cfg, BuildingType::Shipyard, QPoint(-4, 0), Rotation::East)); // dock on asteroid
}
TEST_CASE("BuildingSystem: placing a belt registers it with BeltSystem after construction",
@@ -1192,7 +1192,7 @@ TEST_CASE("BuildingSystem: findRotateInPlaceTarget returns nullopt when tile is
rng);
REQUIRE_FALSE(
bs.findRotateInPlaceTarget(BuildingType::Belt, QPoint(0, 0), Rotation::East).has_value());
findRotateInPlaceTarget(state_bs, cfg, 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)",
@@ -1214,7 +1214,7 @@ TEST_CASE("BuildingSystem: findRotateInPlaceTarget returns the site id for a que
const BuildingId id = bs.place(BuildingType::Belt, QPoint(0, 0), Rotation::East, 0).value();
const std::optional<BuildingId> result =
bs.findRotateInPlaceTarget(BuildingType::Belt, QPoint(0, 0), Rotation::North);
findRotateInPlaceTarget(state_bs, cfg, BuildingType::Belt, QPoint(0, 0), Rotation::North);
REQUIRE(result.has_value());
REQUIRE(*result == id);
}
@@ -1242,7 +1242,7 @@ TEST_CASE("BuildingSystem: findRotateInPlaceTarget returns the building id for a
REQUIRE(getAllSites(state_bs).empty());
const std::optional<BuildingId> result =
bs.findRotateInPlaceTarget(BuildingType::Belt, QPoint(0, 0), Rotation::South);
findRotateInPlaceTarget(state_bs, cfg, BuildingType::Belt, QPoint(0, 0), Rotation::South);
REQUIRE(result.has_value());
REQUIRE(*result == id);
}
@@ -1267,7 +1267,7 @@ TEST_CASE("BuildingSystem: findRotateInPlaceTarget returns nullopt when building
// Querying with Splitter at the same tile — type mismatch → nullopt.
REQUIRE_FALSE(
bs.findRotateInPlaceTarget(BuildingType::Splitter, QPoint(0, 0), Rotation::East).has_value());
findRotateInPlaceTarget(state_bs, cfg, BuildingType::Splitter, QPoint(0, 0), Rotation::East).has_value());
}
TEST_CASE("BuildingSystem: findRotateInPlaceTarget never rotates a tunnel in place",
@@ -1292,9 +1292,9 @@ TEST_CASE("BuildingSystem: findRotateInPlaceTarget never rotates a tunnel in pla
bs.place(BuildingType::TunnelExit, QPoint(-2, 0), Rotation::East, 0);
REQUIRE_FALSE(
bs.findRotateInPlaceTarget(BuildingType::TunnelEntry, QPoint(-1, 0), Rotation::North).has_value());
findRotateInPlaceTarget(state_bs, cfg, BuildingType::TunnelEntry, QPoint(-1, 0), Rotation::North).has_value());
REQUIRE_FALSE(
bs.findRotateInPlaceTarget(BuildingType::TunnelExit, QPoint(-2, 0), Rotation::North).has_value());
findRotateInPlaceTarget(state_bs, cfg, BuildingType::TunnelExit, QPoint(-2, 0), Rotation::North).has_value());
}
TEST_CASE("BuildingSystem: findRotateInPlaceTarget returns nullopt when footprints only partially overlap",
@@ -1319,7 +1319,7 @@ TEST_CASE("BuildingSystem: findRotateInPlaceTarget returns nullopt when footprin
// 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());
findRotateInPlaceTarget(state_bs, cfg, BuildingType::Smelter, QPoint(1, 0), Rotation::East).has_value());
}
TEST_CASE("BuildingSystem: findRotateInPlaceTarget works for a symmetric multi-tile building with rotated ghost",
@@ -1343,7 +1343,7 @@ TEST_CASE("BuildingSystem: findRotateInPlaceTarget works for a symmetric multi-t
const BuildingId id = bs.place(BuildingType::Smelter, QPoint(0, 0), Rotation::East, 0).value();
const std::optional<BuildingId> result =
bs.findRotateInPlaceTarget(BuildingType::Smelter, QPoint(0, 0), Rotation::North);
findRotateInPlaceTarget(state_bs, cfg, BuildingType::Smelter, QPoint(0, 0), Rotation::North);
REQUIRE(result.has_value());
REQUIRE(*result == id);
}
@@ -1507,7 +1507,7 @@ TEST_CASE("BuildingSystem: splitter filters configured on a construction site ca
// The site reports its two output directions and the stored filters before
// it is built; it is not yet registered with BeltSystem.
const std::optional<BeltSystem::SplitterInfo> siteInfo = f.bs.getSiteSplitterInfo(id);
const std::optional<BeltSystem::SplitterInfo> siteInfo = getSiteSplitterInfo(f.state, f.cfg, id);
REQUIRE(siteInfo.has_value());
REQUIRE(siteInfo->filterA == filterA);
REQUIRE(siteInfo->filterB.empty());
@@ -1687,7 +1687,7 @@ TEST_CASE("BuildingSystem: getInputPorts on a miner site lists every input edge"
// Miner mask ["AA","A>"] East → body (0,0),(1,0),(0,1); output tile (1,1) East.
const BuildingId id = f.bs.place(BuildingType::Miner, QPoint(0, 0), Rotation::East, 0).value();
const std::vector<Port> ports = f.bs.getInputPorts(id);
const std::vector<Port> ports = getInputPorts(f.state, f.cfg, id);
// Every perimeter edge except the output-port edge at (1,1), each pointing in.
REQUIRE(ports.size() == 6);
@@ -1710,10 +1710,10 @@ TEST_CASE("BuildingSystem: getInputPorts matches between a site and the built bu
Tick tick = 0;
const BuildingId id = f.bs.place(BuildingType::Miner, QPoint(0, 0), Rotation::East, 0).value();
const std::vector<Port> sitePorts = f.bs.getInputPorts(id);
const std::vector<Port> sitePorts = getInputPorts(f.state, f.cfg, id);
buildToCompletion(f.bs, f.state, f.belts, id, tick);
REQUIRE(findBuilding(f.state, id) != nullptr);
const std::vector<Port> builtPorts = f.bs.getInputPorts(id);
const std::vector<Port> builtPorts = getInputPorts(f.state, f.cfg, id);
// The operational path (stored inputPorts) agrees with the site path (mask-derived).
REQUIRE(builtPorts.size() == sitePorts.size());
@@ -1733,7 +1733,7 @@ TEST_CASE("BuildingSystem: getInputPorts invariants hold for a rotated site", "[
std::set<std::pair<int, int>> bodySet;
for (const QPoint& cell : site->bodyCells) { bodySet.insert({cell.x(), cell.y()}); }
const std::vector<Port> ports = f.bs.getInputPorts(id);
const std::vector<Port> ports = getInputPorts(f.state, f.cfg, id);
REQUIRE_FALSE(ports.empty());
for (const Port& port : ports)
{

View File

@@ -1,4 +1,5 @@
#include "GameWorldView.h"
#include "PlacementRules.h"
#include "FactoryQueries.h"
#include "ProductionRules.h"
@@ -662,7 +663,7 @@ bool GameWorldView::isValidPlacement(BuildingType type, QPoint anchor,
// 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 (!m_sim->getBuildings().isPlacementValid(type, anchor, rot))
if (!isPlacementValid(m_sim->getFactoryState(), m_sim->getConfig(), type, anchor, rot))
{
return false;
}
@@ -683,7 +684,7 @@ bool GameWorldView::isValidPlacement(BuildingType type, QPoint anchor,
if (anyOccupied)
{
return m_sim->getBuildings().findRotateInPlaceTarget(type, anchor, rot).has_value();
return findRotateInPlaceTarget(m_sim->getFactoryState(), m_sim->getConfig(), type, anchor, rot).has_value();
}
return true;
}
@@ -865,8 +866,7 @@ void GameWorldView::placeBlueprintAtTile(QPoint center)
for (const BlueprintBuilding& bb : bp.buildings)
{
if (!m_sim->isBuildingUnlocked(bb.type)) { continue; }
if (m_sim->getBuildings().findRotateInPlaceTarget(
bb.type, center + bb.offset, bb.rotation).has_value())
if (findRotateInPlaceTarget(m_sim->getFactoryState(), m_sim->getConfig(), bb.type, center + bb.offset, bb.rotation).has_value())
{
continue;
}
@@ -880,7 +880,7 @@ void GameWorldView::placeBlueprintAtTile(QPoint center)
if (!m_sim->isBuildingUnlocked(bb.type)) { continue; }
const QPoint anchor = center + bb.offset;
const std::optional<BuildingId> rotateTarget =
m_sim->getBuildings().findRotateInPlaceTarget(bb.type, anchor, bb.rotation);
findRotateInPlaceTarget(m_sim->getFactoryState(), m_sim->getConfig(), bb.type, anchor, bb.rotation);
if (rotateTarget.has_value())
{
std::shared_ptr<RotateInPlaceCommand> rotateCommand =
@@ -1016,7 +1016,7 @@ void GameWorldView::placeAtTile(QPoint tile)
}
const std::optional<BuildingId> rotateTarget =
m_sim->getBuildings().findRotateInPlaceTarget(type, tile, m_ghostRotation);
findRotateInPlaceTarget(m_sim->getFactoryState(), m_sim->getConfig(), type, tile, m_ghostRotation);
if (rotateTarget.has_value())
{
std::shared_ptr<RotateInPlaceCommand> command =
@@ -1080,7 +1080,7 @@ void GameWorldView::recomputeBeltDragPath(QPoint cursorTile)
if (targetId.has_value() && targetType.has_value()
&& *targetType != BuildingType::Belt)
{
const std::vector<Port> inputPorts = m_sim->getBuildings().getInputPorts(*targetId);
const std::vector<Port> inputPorts = getInputPorts(m_sim->getFactoryState(), m_sim->getConfig(), *targetId);
std::optional<Port> best;
float bestDistanceSq = 0.0f;
for (const Port& port : inputPorts)
@@ -1124,8 +1124,7 @@ std::vector<GameWorldView::BeltDragResolved> GameWorldView::resolveBeltDragPath(
{
BeltDragResolved item;
const std::optional<BuildingId> rotateTarget =
m_sim->getBuildings().findRotateInPlaceTarget(
BuildingType::Belt, entry.tile, entry.rotation);
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.

View File

@@ -314,7 +314,7 @@ void SelectedBuildingPanel::buildSingle(BuildingId id)
std::optional<BeltSystem::SplitterInfo> info;
if (m_singleIsSite)
{
info = m_sim->getBuildings().getSiteSplitterInfo(id);
info = getSiteSplitterInfo(m_sim->getFactoryState(), m_sim->getConfig(), id);
}
else
{