2 Commits

Author SHA1 Message Date
71d0dad3f2 give tile occupancy its own class, BuildingGrid
Eleven methods maintained m_tileOccupancy by hand — place, deconstruct,
removeBuilding, placeImmediate, tickDeconstruction, findRotateInPlaceTarget and
tryDirectCoupleDeposit all indexed a raw std::map<std::pair<int,int>, BuildingId>
directly, so the invariant "occupancy stays in sync with placement" was
re-implemented at every call site. They now ask and tell a small owned index
instead: occupy / release / isOccupied / findOwner.

BuildingGrid is a member of BuildingSystem, not a peer system: it has no
per-tick behaviour and nothing outside BuildingSystem touches it.

The internal keying stays std::pair<int,int> rather than moving to QPoint. The
checksum folds the entries in map iteration order, so the comparator is part of
the determinism contract; changing it is a separate decision, not a side effect
of this move. 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 17:22:40 +02:00
fda88fe75c share BuildingSystem's free functions instead of copying them
Four of the five file-local helpers in BuildingSystem.cpp had duplicates
elsewhere: isAutoRecipeBuildingType and isBeltSubsystemType were re-spelled as
isAutoRecipeBuilding and isBeltLike in SelectedBuildingPanel.cpp, and
outputBodyTile was copied verbatim as portBodyTile in GameWorldView.cpp. Same
predicates, different names, so a change to one would silently not reach the
others.

The two BuildingType predicates move to BuildingType.h, which already hosts the
free functions over that enum and is already included by both lib and ui. The
port geometry moves to a new PortGeometry.h; inputBodyTile has no duplicate but
is outputBodyTile's counterpart and belongs beside it — the sim moves items
across the port edge and the renderer draws the virtual belt there, so the two
must agree on which tile a port owns.

inputLaneEntryFree stays file-local: single use, and tied to BeltItemSlot rather
than to building types or port geometry.

Verified the six moved bodies are character-identical to their originals.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YHcUerKAZKWNvSKJxYKbnG
2026-08-04 16:09:01 +02:00
11 changed files with 201 additions and 128 deletions

View File

@@ -38,3 +38,17 @@ std::string buildingTypeId(BuildingType type)
}
return "";
}
bool isAutoRecipeBuildingType(BuildingType type)
{
return type == BuildingType::Smelter
|| type == BuildingType::ReprocessingPlant;
}
bool isBeltSubsystemType(BuildingType type)
{
return type == BuildingType::Belt
|| type == BuildingType::Splitter
|| type == BuildingType::TunnelEntry
|| type == BuildingType::TunnelExit;
}

View File

@@ -29,3 +29,13 @@ std::optional<BuildingType> parseBuildingType(const std::string& id);
// Canonical id string for a BuildingType. The inverse of parseBuildingType.
std::string buildingTypeId(BuildingType type);
// Smelter and Reprocessing Plant have no player-selected recipe
// (REQ-BLD-SMELTER, REQ-BLD-REPROCESSING). They auto-process whatever inputs
// they receive, matching against every recipe of their building type.
bool isAutoRecipeBuildingType(BuildingType type);
// Belts, splitters, and tunnel ends keep their runtime data in the belt subsystem
// rather than in the Building instance, so placing/removing them must register or
// unregister a tile with BeltSystem.
bool isBeltSubsystemType(BuildingType type);

View File

@@ -9,6 +9,7 @@ SET(HDRS
${CMAKE_CURRENT_SOURCE_DIR}/ItemType.h
${CMAKE_CURRENT_SOURCE_DIR}/Item.h
${CMAKE_CURRENT_SOURCE_DIR}/Port.h
${CMAKE_CURRENT_SOURCE_DIR}/PortGeometry.h
${CMAKE_CURRENT_SOURCE_DIR}/SchematicChoiceOption.h
${CMAKE_CURRENT_SOURCE_DIR}/DisplayName.h
${CMAKE_CURRENT_SOURCE_DIR}/BeltDragPath.h

View File

@@ -0,0 +1,45 @@
#pragma once
#include <QPoint>
#include "Rotation.h"
// Geometry of a building's input/output ports. A Port names the tile *outside* the
// building together with the direction items flow across it; these helpers give the
// building body tile on the other side of that edge, which is where the virtual
// input/output belt lives.
//
// Shared by the simulation (which moves items across the edge) and the renderer
// (which draws the virtual belt), so the two cannot disagree about which tile a
// port belongs to.
// The building body tile that owns an output port, given the port's outside tile
// (port.tile) and its facing direction. The virtual output belt occupies this tile
// and flows toward port.tile (REQ-MAT-OUTPUT-EMERGE).
inline QPoint outputBodyTile(QPoint portTile, Rotation direction)
{
switch (direction)
{
case Rotation::East: return portTile + QPoint(-1, 0);
case Rotation::West: return portTile + QPoint( 1, 0);
case Rotation::North: return portTile + QPoint( 0, 1);
case Rotation::South: return portTile + QPoint( 0, -1);
}
return portTile;
}
// The building body tile an input port feeds into, given the port's outside belt
// tile (port.tile) and its inward flow direction. The virtual input belt occupies
// this tile and flows from the outer edge (progress 0.0) to the centre (0.5)
// (REQ-MAT-INPUT-INTAKE).
inline QPoint inputBodyTile(QPoint portTile, Rotation inwardDirection)
{
switch (inwardDirection)
{
case Rotation::East: return portTile + QPoint( 1, 0);
case Rotation::West: return portTile + QPoint(-1, 0);
case Rotation::North: return portTile + QPoint( 0, -1);
case Rotation::South: return portTile + QPoint( 0, 1);
}
return portTile;
}

View File

@@ -0,0 +1,52 @@
#include "BuildingGrid.h"
#include "StateChecksum.h"
void BuildingGrid::occupy(QPoint cell, BuildingId id)
{
m_owners[{cell.x(), cell.y()}] = id;
}
void BuildingGrid::occupy(const std::vector<QPoint>& cells, BuildingId id)
{
for (const QPoint& cell : cells)
{
occupy(cell, id);
}
}
void BuildingGrid::release(const std::vector<QPoint>& cells)
{
for (const QPoint& cell : cells)
{
m_owners.erase({cell.x(), cell.y()});
}
}
bool BuildingGrid::isOccupied(QPoint tile) const
{
return m_owners.count({tile.x(), tile.y()}) > 0;
}
std::optional<BuildingId> BuildingGrid::findOwner(QPoint tile) const
{
const std::map<std::pair<int, int>, BuildingId>::const_iterator it =
m_owners.find({tile.x(), tile.y()});
if (it == m_owners.end())
{
return std::nullopt;
}
return it->second;
}
void BuildingGrid::appendChecksum(Hasher& hasher) const
{
// std::map iterates in sorted key order.
hasher.append(m_owners.size());
for (const std::pair<const std::pair<int, int>, BuildingId>& entry : m_owners)
{
hasher.append(entry.first.first);
hasher.append(entry.first.second);
hasher.append(entry.second);
}
}

View File

@@ -0,0 +1,46 @@
#pragma once
#include <map>
#include <optional>
#include <utility>
#include <vector>
#include <QPoint>
#include "BuildingId.h"
class Hasher;
// The authority on which building owns which world tile.
//
// Every building and construction site claims its body cells here when it is placed
// and releases them when it is removed, so the map is the single place that knows
// whether a tile is free. It is a plain index owned by BuildingSystem, not a system:
// it has no per-tick behaviour and nothing outside BuildingSystem touches it.
//
// Keys are deliberately std::pair<int, int> rather than QPoint: the checksum folds the
// entries in map iteration order (docs/replay_design.md), so the comparator is part of
// the determinism contract and is not changed casually.
class BuildingGrid
{
public:
// Records absolute body cells as owned by id. Re-occupying a cell overwrites its
// previous owner, matching the placement paths that reserve cells for a site and
// then hand them to the building it becomes.
void occupy(QPoint cell, BuildingId id);
void occupy(const std::vector<QPoint>& cells, BuildingId id);
// Releases absolute body cells. Cells that are not occupied are ignored.
void release(const std::vector<QPoint>& cells);
bool isOccupied(QPoint tile) const;
// The building owning the tile, or nullopt when the tile is free.
std::optional<BuildingId> findOwner(QPoint tile) const;
// Folds the occupancy into the hasher in deterministic order.
void appendChecksum(Hasher& hasher) const;
private:
std::map<std::pair<int, int>, BuildingId> m_owners;
};

View File

@@ -6,63 +6,13 @@
#include <random>
#include <set>
#include "PortGeometry.h"
#include "StateChecksum.h"
#include "SurfaceMask.h"
#include "tracing.h"
namespace
{
// Smelter and Reprocessing Plant have no player-selected recipe
// (REQ-BLD-SMELTER, REQ-BLD-REPROCESSING). They auto-process whatever inputs
// they receive, matching against every recipe of their building type.
bool isAutoRecipeBuildingType(BuildingType type)
{
return type == BuildingType::Smelter
|| type == BuildingType::ReprocessingPlant;
}
// Belts, splitters, and tunnel ends keep their runtime data in the belt subsystem
// rather than in the Building instance, so placing/removing them must register or
// unregister a tile with BeltSystem.
bool isBeltSubsystemType(BuildingType type)
{
return type == BuildingType::Belt
|| type == BuildingType::Splitter
|| type == BuildingType::TunnelEntry
|| type == BuildingType::TunnelExit;
}
// The building body tile that owns an output port, given the port's outside tile
// (port.tile) and its facing direction. The virtual output belt occupies this tile
// and flows toward port.tile (REQ-MAT-OUTPUT-EMERGE).
QPoint outputBodyTile(QPoint portTile, Rotation direction)
{
switch (direction)
{
case Rotation::East: return portTile + QPoint(-1, 0);
case Rotation::West: return portTile + QPoint( 1, 0);
case Rotation::North: return portTile + QPoint( 0, 1);
case Rotation::South: return portTile + QPoint( 0, -1);
}
return portTile;
}
// The building body tile an input port feeds into, given the port's outside belt
// tile (port.tile) and its inward flow direction. The virtual input belt occupies
// this tile and flows from the outer edge (progress 0.0) to the centre (0.5)
// (REQ-MAT-INPUT-INTAKE).
QPoint inputBodyTile(QPoint portTile, Rotation inwardDirection)
{
switch (inwardDirection)
{
case Rotation::East: return portTile + QPoint( 1, 0);
case Rotation::West: return portTile + QPoint(-1, 0);
case Rotation::North: return portTile + QPoint( 0, -1);
case Rotation::South: return portTile + QPoint( 0, 1);
}
return portTile;
}
// An input belt accepts a new item at progress 0.0 only when it holds fewer than
// three items and the entry slot is clear (nothing within a quarter tile of 0.0),
// matching the belt packing used elsewhere (REQ-GW-BELT-CAPACITY).
@@ -358,7 +308,7 @@ std::optional<BuildingId> BuildingSystem::place(BuildingType type, QPoint anchor
for (const QPoint& cell : mask.bodyCells)
{
const QPoint absCell = anchor + cell;
m_tileOccupancy[{absCell.x(), absCell.y()}] = id;
m_grid.occupy(absCell, id);
}
// Build construction site.
@@ -462,10 +412,7 @@ int BuildingSystem::deconstruct(BuildingId id, Tick currentTick)
if (it->id == id)
{
const BuildingDef* def = m_config.buildings.findBuildingDef(it->type);
for (const QPoint& cell : it->bodyCells)
{
m_tileOccupancy.erase({cell.x(), cell.y()});
}
m_grid.release(it->bodyCells);
m_constructionQueue.erase(it);
if (def)
{
@@ -849,10 +796,7 @@ void BuildingSystem::tickDeconstruction(Tick currentTick)
if (it->id != front.id) { continue; }
const BuildingDef* def = m_config.buildings.findBuildingDef(it->type);
for (const QPoint& cell : it->bodyCells)
{
m_tileOccupancy.erase({cell.x(), cell.y()});
}
m_grid.release(it->bodyCells);
m_buildings.erase(it);
if (def)
{
@@ -988,14 +932,13 @@ bool BuildingSystem::tryDirectCoupleDeposit(BuildingId producerId,
const Port& outputPort,
const Item& item)
{
const std::map<std::pair<int, int>, BuildingId>::const_iterator occIt =
m_tileOccupancy.find({outputPort.tile.x(), outputPort.tile.y()});
if (occIt == m_tileOccupancy.end() || occIt->second == producerId)
const std::optional<BuildingId> ownerId = m_grid.findOwner(outputPort.tile);
if (!ownerId.has_value() || *ownerId == producerId)
{
return false;
}
Building* consumer = findBuildingMutable(occIt->second);
Building* consumer = findBuildingMutable(*ownerId);
if (!consumer)
{
return false; // an unbuilt construction site, or not an operational building
@@ -1577,7 +1520,7 @@ std::vector<BuildingSystem::BeltTileInfo> BuildingSystem::getAllBeltTiles() cons
bool BuildingSystem::isTileOccupied(QPoint tile) const
{
return m_tileOccupancy.count({tile.x(), tile.y()}) > 0;
return m_grid.isOccupied(tile);
}
std::optional<BuildingId> BuildingSystem::findRotateInPlaceTarget(
@@ -1598,15 +1541,14 @@ std::optional<BuildingId> BuildingSystem::findRotateInPlaceTarget(
// All body cells must be occupied by the same entity.
const QPoint firstAbs = anchor + mask.bodyCells[0];
const auto firstIt = m_tileOccupancy.find({firstAbs.x(), firstAbs.y()});
if (firstIt == m_tileOccupancy.end()) { return std::nullopt; }
const BuildingId candidateId = firstIt->second;
const std::optional<BuildingId> firstOwner = m_grid.findOwner(firstAbs);
if (!firstOwner.has_value()) { return std::nullopt; }
const BuildingId candidateId = *firstOwner;
for (const QPoint& rel : mask.bodyCells)
{
const QPoint abs = anchor + rel;
const auto it = m_tileOccupancy.find({abs.x(), abs.y()});
if (it == m_tileOccupancy.end() || it->second != candidateId)
const std::optional<BuildingId> owner = m_grid.findOwner(anchor + rel);
if (!owner.has_value() || *owner != candidateId)
{
return std::nullopt;
}
@@ -1766,7 +1708,7 @@ BuildingId BuildingSystem::placeImmediate(BuildingType type,
{
const QPoint absCell = anchor + cell;
building.bodyCells.push_back(absCell);
m_tileOccupancy[{absCell.x(), absCell.y()}] = id;
m_grid.occupy(absCell, id);
}
for (const Port& port : mask.outputPorts)
{
@@ -1801,10 +1743,7 @@ bool BuildingSystem::removeBuilding(BuildingId id)
{
m_belts.removeTile(it->anchor);
}
for (const QPoint& cell : it->bodyCells)
{
m_tileOccupancy.erase({cell.x(), cell.y()});
}
m_grid.release(it->bodyCells);
m_buildings.erase(it);
return true;
}
@@ -1823,18 +1762,12 @@ void BuildingSystem::forEachBuilding(std::function<void(Building&)> fn)
void BuildingSystem::registerTileOccupancy(const std::vector<QPoint>& cells,
BuildingId ownerPlaceholder)
{
for (const QPoint& cell : cells)
{
m_tileOccupancy[{cell.x(), cell.y()}] = ownerPlaceholder;
}
m_grid.occupy(cells, ownerPlaceholder);
}
void BuildingSystem::unregisterTileOccupancy(const std::vector<QPoint>& cells)
{
for (const QPoint& cell : cells)
{
m_tileOccupancy.erase({cell.x(), cell.y()});
}
m_grid.release(cells);
}
namespace
@@ -1943,12 +1876,5 @@ void BuildingSystem::appendChecksum(Hasher& hasher) const
for (const ItemType& type : e.splitterFilterB) { hasher.append(type.id); }
}
// std::map iterates in sorted key order.
hasher.append(m_tileOccupancy.size());
for (const std::pair<const std::pair<int, int>, BuildingId>& entry : m_tileOccupancy)
{
hasher.append(entry.first.first);
hasher.append(entry.first.second);
hasher.append(entry.second);
}
m_grid.appendChecksum(hasher);
}

View File

@@ -15,6 +15,7 @@
#include "BeltSystem.h"
#include "Building.h"
#include "BuildingGrid.h"
#include "BuildingType.h"
#include "BuildingId.h"
#include "GameConfig.h"
@@ -309,6 +310,7 @@ private:
};
std::deque<DeconstructionEntry> m_deconstructionQueue;
// Maps every occupied body-cell coordinate to the entity that owns it.
std::map<std::pair<int, int>, BuildingId> m_tileOccupancy;
// The authority on which building owns which tile; every placement and removal
// path claims and releases its body cells here.
BuildingGrid m_grid;
};

View File

@@ -12,6 +12,7 @@ SET(HDRS
${CMAKE_CURRENT_SOURCE_DIR}/BeltSystem.h
${CMAKE_CURRENT_SOURCE_DIR}/Building.h
${CMAKE_CURRENT_SOURCE_DIR}/BuildingConfig.h
${CMAKE_CURRENT_SOURCE_DIR}/BuildingGrid.h
${CMAKE_CURRENT_SOURCE_DIR}/BuildingSystem.h
${CMAKE_CURRENT_SOURCE_DIR}/EntityHitTest.h
${CMAKE_CURRENT_SOURCE_DIR}/ShipLayout.h
@@ -36,6 +37,7 @@ SET(SRCS
${CMAKE_CURRENT_SOURCE_DIR}/BeltSlot.cpp
${CMAKE_CURRENT_SOURCE_DIR}/BeltSystem.cpp
${CMAKE_CURRENT_SOURCE_DIR}/BuildingConfig.cpp
${CMAKE_CURRENT_SOURCE_DIR}/BuildingGrid.cpp
${CMAKE_CURRENT_SOURCE_DIR}/BuildingSystem.cpp
${CMAKE_CURRENT_SOURCE_DIR}/EntityHitTest.cpp
${CMAKE_CURRENT_SOURCE_DIR}/ShipStatsCalculator.cpp

View File

@@ -50,6 +50,7 @@
#include "HealthComponent.h"
#include "HqProxyComponent.h"
#include "ItemIconCache.h"
#include "PortGeometry.h"
#include "PositionComponent.h"
#include "RepairBehavior.h"
#include "SalvageScrapBehavior.h"
@@ -166,18 +167,6 @@ Rotation rotateCounterClockwise(Rotation r)
return Rotation::East;
}
QPoint portBodyTile(QPoint portTile, Rotation direction)
{
switch (direction)
{
case Rotation::East: return portTile + QPoint(-1, 0);
case Rotation::West: return portTile + QPoint( 1, 0);
case Rotation::North: return portTile + QPoint( 0, 1);
case Rotation::South: return portTile + QPoint( 0, -1);
}
return portTile;
}
// Fill color for a building's status light per its production state
// (REQ-UI-STATUS-LIGHT).
QColor statusLightFill(ProductionStatus status, const StatusLightVisuals& sl)
@@ -1341,7 +1330,7 @@ void GameWorldView::drawBuildings(QPainter& painter)
for (const Port& port : b.outputPorts)
{
drawPortGlyph(painter, portBodyTile(port.tile, port.direction),
drawPortGlyph(painter, outputBodyTile(port.tile, port.direction),
port.direction, bv.outline, /*centered*/ false);
}
@@ -1441,7 +1430,7 @@ void GameWorldView::drawBuildings(QPainter& painter)
for (const Port& port : siteMask.outputPorts)
{
const QPoint absBody = s.anchor
+ portBodyTile(port.tile, port.direction);
+ outputBodyTile(port.tile, port.direction);
drawPortGlyph(painter, absBody, port.direction, bv.outline,
/*centered*/ false);
}
@@ -2215,7 +2204,7 @@ void GameWorldView::drawBuildingGhost(QPainter& painter, BuildingType type,
for (const Port& port : parsed.outputPorts)
{
drawPortGlyph(painter, anchorTile + portBodyTile(port.tile, port.direction),
drawPortGlyph(painter, anchorTile + outputBodyTile(port.tile, port.direction),
port.direction, lineColor, /*centered*/ false);
}

View File

@@ -84,20 +84,6 @@ bool hasRecipeSelection(BuildingType type)
|| type == BuildingType::Shipyard;
}
// Auto-recipe buildings have no selected recipe; their production is driven by
// whatever inputs they receive.
bool isAutoRecipeBuilding(BuildingType type)
{
return type == BuildingType::Smelter
|| type == BuildingType::ReprocessingPlant;
}
bool isBeltLike(BuildingType type)
{
return type == BuildingType::Belt || type == BuildingType::Splitter
|| type == BuildingType::TunnelEntry || type == BuildingType::TunnelExit;
}
QString rotationLabel(Rotation r)
{
switch (r)
@@ -313,7 +299,7 @@ void SelectedBuildingPanel::buildSingle(BuildingId id)
// Belt "Clear" removes items from a live belt tile; a construction site has
// none and is not registered with BeltSystem yet, so hide it for sites.
if (isBeltLike(type) && !m_singleIsSite)
if (isBeltSubsystemType(type) && !m_singleIsSite)
{
m_clearBeltBtn->show();
}
@@ -394,7 +380,7 @@ void SelectedBuildingPanel::refreshBuffers(const Building* b)
// Auto-recipe buildings (Smelter, Reprocessing Plant) have no selected
// recipe; while a cycle runs, resolve the recipe actually in production so
// the cycle time and progress can be shown (REQ-UI-PRODUCTION-PROGRESS).
if (!recipe && isAutoRecipeBuilding(b->type) && b->production.has_value())
if (!recipe && isAutoRecipeBuildingType(b->type) && b->production.has_value())
{
recipe = m_config->recipes.findRecipeDef(b->production->recipeId, b->type);
}
@@ -488,7 +474,7 @@ void SelectedBuildingPanel::refreshBuffers(const Building* b)
}
if (isProductionBuilding(b->type)
&& (recipe || shipDef || isAutoRecipeBuilding(b->type)))
&& (recipe || shipDef || isAutoRecipeBuildingType(b->type)))
{
if (recipe || shipDef)
{
@@ -683,7 +669,7 @@ void SelectedBuildingPanel::buildMulti(const std::vector<BuildingId>& ids)
{
text += buildingTypeName(entry.first) + " x "
+ QString::number(entry.second) + "\n";
if (isBeltLike(entry.first))
if (isBeltSubsystemType(entry.first))
{
hasBelt = true;
}
@@ -832,7 +818,7 @@ void SelectedBuildingPanel::onClearBelt()
for (BuildingId id : m_selectedBuildingIds)
{
const Building* b = m_sim->getBuildings().findBuilding(id);
if (b && isBeltLike(b->type))
if (b && isBeltSubsystemType(b->type))
{
for (const QPoint& cell : b->bodyCells)
{