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
This commit is contained in:
2026-08-04 17:22:40 +02:00
parent fda88fe75c
commit 71d0dad3f2
5 changed files with 121 additions and 43 deletions

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

@@ -308,7 +308,7 @@ std::optional<BuildingId> BuildingSystem::place(BuildingType type, QPoint anchor
for (const QPoint& cell : mask.bodyCells) for (const QPoint& cell : mask.bodyCells)
{ {
const QPoint absCell = anchor + cell; const QPoint absCell = anchor + cell;
m_tileOccupancy[{absCell.x(), absCell.y()}] = id; m_grid.occupy(absCell, id);
} }
// Build construction site. // Build construction site.
@@ -412,10 +412,7 @@ int BuildingSystem::deconstruct(BuildingId id, Tick currentTick)
if (it->id == id) if (it->id == id)
{ {
const BuildingDef* def = m_config.buildings.findBuildingDef(it->type); const BuildingDef* def = m_config.buildings.findBuildingDef(it->type);
for (const QPoint& cell : it->bodyCells) m_grid.release(it->bodyCells);
{
m_tileOccupancy.erase({cell.x(), cell.y()});
}
m_constructionQueue.erase(it); m_constructionQueue.erase(it);
if (def) if (def)
{ {
@@ -799,10 +796,7 @@ void BuildingSystem::tickDeconstruction(Tick currentTick)
if (it->id != front.id) { continue; } if (it->id != front.id) { continue; }
const BuildingDef* def = m_config.buildings.findBuildingDef(it->type); const BuildingDef* def = m_config.buildings.findBuildingDef(it->type);
for (const QPoint& cell : it->bodyCells) m_grid.release(it->bodyCells);
{
m_tileOccupancy.erase({cell.x(), cell.y()});
}
m_buildings.erase(it); m_buildings.erase(it);
if (def) if (def)
{ {
@@ -938,14 +932,13 @@ bool BuildingSystem::tryDirectCoupleDeposit(BuildingId producerId,
const Port& outputPort, const Port& outputPort,
const Item& item) const Item& item)
{ {
const std::map<std::pair<int, int>, BuildingId>::const_iterator occIt = const std::optional<BuildingId> ownerId = m_grid.findOwner(outputPort.tile);
m_tileOccupancy.find({outputPort.tile.x(), outputPort.tile.y()}); if (!ownerId.has_value() || *ownerId == producerId)
if (occIt == m_tileOccupancy.end() || occIt->second == producerId)
{ {
return false; return false;
} }
Building* consumer = findBuildingMutable(occIt->second); Building* consumer = findBuildingMutable(*ownerId);
if (!consumer) if (!consumer)
{ {
return false; // an unbuilt construction site, or not an operational building return false; // an unbuilt construction site, or not an operational building
@@ -1527,7 +1520,7 @@ std::vector<BuildingSystem::BeltTileInfo> BuildingSystem::getAllBeltTiles() cons
bool BuildingSystem::isTileOccupied(QPoint tile) const 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( std::optional<BuildingId> BuildingSystem::findRotateInPlaceTarget(
@@ -1548,15 +1541,14 @@ std::optional<BuildingId> BuildingSystem::findRotateInPlaceTarget(
// All body cells must be occupied by the same entity. // All body cells must be occupied by the same entity.
const QPoint firstAbs = anchor + mask.bodyCells[0]; const QPoint firstAbs = anchor + mask.bodyCells[0];
const auto firstIt = m_tileOccupancy.find({firstAbs.x(), firstAbs.y()}); const std::optional<BuildingId> firstOwner = m_grid.findOwner(firstAbs);
if (firstIt == m_tileOccupancy.end()) { return std::nullopt; } if (!firstOwner.has_value()) { return std::nullopt; }
const BuildingId candidateId = firstIt->second; const BuildingId candidateId = *firstOwner;
for (const QPoint& rel : mask.bodyCells) for (const QPoint& rel : mask.bodyCells)
{ {
const QPoint abs = anchor + rel; const std::optional<BuildingId> owner = m_grid.findOwner(anchor + rel);
const auto it = m_tileOccupancy.find({abs.x(), abs.y()}); if (!owner.has_value() || *owner != candidateId)
if (it == m_tileOccupancy.end() || it->second != candidateId)
{ {
return std::nullopt; return std::nullopt;
} }
@@ -1716,7 +1708,7 @@ BuildingId BuildingSystem::placeImmediate(BuildingType type,
{ {
const QPoint absCell = anchor + cell; const QPoint absCell = anchor + cell;
building.bodyCells.push_back(absCell); building.bodyCells.push_back(absCell);
m_tileOccupancy[{absCell.x(), absCell.y()}] = id; m_grid.occupy(absCell, id);
} }
for (const Port& port : mask.outputPorts) for (const Port& port : mask.outputPorts)
{ {
@@ -1751,10 +1743,7 @@ bool BuildingSystem::removeBuilding(BuildingId id)
{ {
m_belts.removeTile(it->anchor); m_belts.removeTile(it->anchor);
} }
for (const QPoint& cell : it->bodyCells) m_grid.release(it->bodyCells);
{
m_tileOccupancy.erase({cell.x(), cell.y()});
}
m_buildings.erase(it); m_buildings.erase(it);
return true; return true;
} }
@@ -1773,18 +1762,12 @@ void BuildingSystem::forEachBuilding(std::function<void(Building&)> fn)
void BuildingSystem::registerTileOccupancy(const std::vector<QPoint>& cells, void BuildingSystem::registerTileOccupancy(const std::vector<QPoint>& cells,
BuildingId ownerPlaceholder) BuildingId ownerPlaceholder)
{ {
for (const QPoint& cell : cells) m_grid.occupy(cells, ownerPlaceholder);
{
m_tileOccupancy[{cell.x(), cell.y()}] = ownerPlaceholder;
}
} }
void BuildingSystem::unregisterTileOccupancy(const std::vector<QPoint>& cells) void BuildingSystem::unregisterTileOccupancy(const std::vector<QPoint>& cells)
{ {
for (const QPoint& cell : cells) m_grid.release(cells);
{
m_tileOccupancy.erase({cell.x(), cell.y()});
}
} }
namespace namespace
@@ -1893,12 +1876,5 @@ void BuildingSystem::appendChecksum(Hasher& hasher) const
for (const ItemType& type : e.splitterFilterB) { hasher.append(type.id); } for (const ItemType& type : e.splitterFilterB) { hasher.append(type.id); }
} }
// std::map iterates in sorted key order. m_grid.appendChecksum(hasher);
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);
}
} }

View File

@@ -15,6 +15,7 @@
#include "BeltSystem.h" #include "BeltSystem.h"
#include "Building.h" #include "Building.h"
#include "BuildingGrid.h"
#include "BuildingType.h" #include "BuildingType.h"
#include "BuildingId.h" #include "BuildingId.h"
#include "GameConfig.h" #include "GameConfig.h"
@@ -309,6 +310,7 @@ private:
}; };
std::deque<DeconstructionEntry> m_deconstructionQueue; std::deque<DeconstructionEntry> m_deconstructionQueue;
// Maps every occupied body-cell coordinate to the entity that owns it. // The authority on which building owns which tile; every placement and removal
std::map<std::pair<int, int>, BuildingId> m_tileOccupancy; // 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}/BeltSystem.h
${CMAKE_CURRENT_SOURCE_DIR}/Building.h ${CMAKE_CURRENT_SOURCE_DIR}/Building.h
${CMAKE_CURRENT_SOURCE_DIR}/BuildingConfig.h ${CMAKE_CURRENT_SOURCE_DIR}/BuildingConfig.h
${CMAKE_CURRENT_SOURCE_DIR}/BuildingGrid.h
${CMAKE_CURRENT_SOURCE_DIR}/BuildingSystem.h ${CMAKE_CURRENT_SOURCE_DIR}/BuildingSystem.h
${CMAKE_CURRENT_SOURCE_DIR}/EntityHitTest.h ${CMAKE_CURRENT_SOURCE_DIR}/EntityHitTest.h
${CMAKE_CURRENT_SOURCE_DIR}/ShipLayout.h ${CMAKE_CURRENT_SOURCE_DIR}/ShipLayout.h
@@ -36,6 +37,7 @@ SET(SRCS
${CMAKE_CURRENT_SOURCE_DIR}/BeltSlot.cpp ${CMAKE_CURRENT_SOURCE_DIR}/BeltSlot.cpp
${CMAKE_CURRENT_SOURCE_DIR}/BeltSystem.cpp ${CMAKE_CURRENT_SOURCE_DIR}/BeltSystem.cpp
${CMAKE_CURRENT_SOURCE_DIR}/BuildingConfig.cpp ${CMAKE_CURRENT_SOURCE_DIR}/BuildingConfig.cpp
${CMAKE_CURRENT_SOURCE_DIR}/BuildingGrid.cpp
${CMAKE_CURRENT_SOURCE_DIR}/BuildingSystem.cpp ${CMAKE_CURRENT_SOURCE_DIR}/BuildingSystem.cpp
${CMAKE_CURRENT_SOURCE_DIR}/EntityHitTest.cpp ${CMAKE_CURRENT_SOURCE_DIR}/EntityHitTest.cpp
${CMAKE_CURRENT_SOURCE_DIR}/ShipStatsCalculator.cpp ${CMAKE_CURRENT_SOURCE_DIR}/ShipStatsCalculator.cpp