BuildingSystem::findBuildingDef was a byte-equivalent re-implementation of BuildingsConfig::findBuildingDef. Same cleanup as the GameWorldView copy; this one sits in the sim layer, so it was missed by both earlier passes. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GH8ZMRY3vhxxXcaUxBqxkk
1955 lines
65 KiB
C++
1955 lines
65 KiB
C++
#include "BuildingSystem.h"
|
||
|
||
#include <algorithm>
|
||
#include <cassert>
|
||
#include <limits>
|
||
#include <random>
|
||
#include <set>
|
||
|
||
#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).
|
||
bool inputLaneEntryFree(const std::vector<BeltItemSlot>& lane)
|
||
{
|
||
return lane.size() < 3 && (lane.empty() || lane.back().progress >= 0.25);
|
||
}
|
||
} // namespace
|
||
|
||
BuildingSystem::BuildingSystem(const GameConfig& config,
|
||
BeltSystem& belts,
|
||
std::function<BuildingId()> allocateBuildingId,
|
||
std::function<void(int)> addBuildingBlocks,
|
||
std::function<void(const std::string&, QVector2D,
|
||
const std::optional<ShipLayoutConfig>&)> spawnShip,
|
||
std::function<bool(const std::string&)> isItemUnlocked,
|
||
std::mt19937& rng)
|
||
: m_config(config)
|
||
, m_belts(belts)
|
||
, m_allocateBuildingId(std::move(allocateBuildingId))
|
||
, m_addBuildingBlocks(std::move(addBuildingBlocks))
|
||
, m_spawnShip(std::move(spawnShip))
|
||
, m_isItemUnlocked(std::move(isItemUnlocked))
|
||
, m_rng(rng)
|
||
, m_asteroidWidth_tiles(config.world.regions.asteroidWidth_tiles)
|
||
{
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Private helpers
|
||
// ---------------------------------------------------------------------------
|
||
|
||
void BuildingSystem::initBuffers(Building& b, const RecipeDef& recipe) const
|
||
{
|
||
b.inputBuffer.counts.clear();
|
||
b.inputBuffer.caps.clear();
|
||
for (const RecipeIngredient& ing : recipe.inputs)
|
||
{
|
||
const ItemType type{ing.item};
|
||
b.inputBuffer.counts[type] = 0;
|
||
b.inputBuffer.caps[type] = 2 * ing.amount;
|
||
}
|
||
|
||
b.outputBuffer.items.clear();
|
||
if (b.type == BuildingType::ReprocessingPlant)
|
||
{
|
||
// 1× max-per-roll (REQ-MAT-OUTPUT-BUFFER-REPROCESSING).
|
||
int maxAmount = 0;
|
||
for (const RecipeOutput& out : recipe.outputs)
|
||
{
|
||
if (out.amount > maxAmount)
|
||
{
|
||
maxAmount = out.amount;
|
||
}
|
||
}
|
||
b.outputBuffer.capacity = maxAmount;
|
||
}
|
||
else
|
||
{
|
||
// 2× per-cycle output.
|
||
int totalAmount = 0;
|
||
for (const RecipeOutput& out : recipe.outputs)
|
||
{
|
||
totalAmount += out.amount;
|
||
}
|
||
b.outputBuffer.capacity = 2 * totalAmount;
|
||
}
|
||
}
|
||
|
||
void BuildingSystem::initAutoBuffers(Building& b) const
|
||
{
|
||
b.inputBuffer.counts.clear();
|
||
b.inputBuffer.caps.clear();
|
||
|
||
// Union the inputs of every recipe of this building type; the cap for each
|
||
// item is twice the largest per-cycle requirement across those recipes.
|
||
// Output capacity follows the same rules as initBuffers: the Reprocessing
|
||
// Plant holds one cycle's max output (REQ-MAT-OUTPUT-BUFFER-REPROCESSING),
|
||
// other auto buildings hold twice the largest per-cycle output.
|
||
int outputCapacity = 0;
|
||
for (const RecipeDef& recipe : m_config.recipes.recipes)
|
||
{
|
||
if (recipe.building != b.type)
|
||
{
|
||
continue;
|
||
}
|
||
|
||
for (const RecipeIngredient& ing : recipe.inputs)
|
||
{
|
||
const ItemType type{ing.item};
|
||
b.inputBuffer.counts[type] = 0;
|
||
b.inputBuffer.caps[type] =
|
||
std::max(b.inputBuffer.caps[type], 2 * ing.amount);
|
||
}
|
||
|
||
if (b.type == BuildingType::ReprocessingPlant)
|
||
{
|
||
int maxAmount = 0;
|
||
for (const RecipeOutput& out : recipe.outputs)
|
||
{
|
||
maxAmount = std::max(maxAmount, out.amount);
|
||
}
|
||
outputCapacity = std::max(outputCapacity, maxAmount);
|
||
}
|
||
else
|
||
{
|
||
int totalAmount = 0;
|
||
for (const RecipeOutput& out : recipe.outputs)
|
||
{
|
||
totalAmount += out.amount;
|
||
}
|
||
outputCapacity = std::max(outputCapacity, 2 * totalAmount);
|
||
}
|
||
}
|
||
|
||
b.outputBuffer.items.clear();
|
||
b.outputBuffer.capacity = outputCapacity;
|
||
}
|
||
|
||
void BuildingSystem::initShipyardBuffers(Building& b) const
|
||
{
|
||
b.inputBuffer.counts.clear();
|
||
b.inputBuffer.caps.clear();
|
||
b.outputBuffer.items.clear();
|
||
b.outputBuffer.capacity = 0;
|
||
const ShipDef* def = m_config.ships.findShipDef(b.recipeId);
|
||
if (!def)
|
||
{
|
||
return;
|
||
}
|
||
for (const RecipeIngredient& ing : def->schematic.materials)
|
||
{
|
||
const ItemType type{ing.item};
|
||
b.inputBuffer.counts[type] = 0;
|
||
b.inputBuffer.caps[type] = 2 * ing.amount;
|
||
}
|
||
if (b.shipLayout.has_value())
|
||
{
|
||
for (const PlacedModule& pm : b.shipLayout->placedModules)
|
||
{
|
||
const ModuleDef* modDef = m_config.modules.findModuleDef(pm.moduleId);
|
||
if (!modDef)
|
||
{
|
||
continue;
|
||
}
|
||
for (const RecipeIngredient& ing : modDef->materials)
|
||
{
|
||
const ItemType type{ing.item};
|
||
b.inputBuffer.counts.try_emplace(type, 0);
|
||
b.inputBuffer.caps[type] += 2 * ing.amount;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
void BuildingSystem::initSalvageBayBuffer(Building& b) const
|
||
{
|
||
// Salvage Bay has no recipe-driven buffer; its output-buffer holding size for
|
||
// ship drop-off is config-defined (REQ-BLD-SALVAGE-BAY).
|
||
b.outputBuffer.items.clear();
|
||
const BuildingDef* def = m_config.buildings.findBuildingDef(BuildingType::SalvageBay);
|
||
b.outputBuffer.capacity =
|
||
(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(id))
|
||
{
|
||
return building->inputPorts;
|
||
}
|
||
if (const ConstructionSite* site = findSite(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)
|
||
{
|
||
std::vector<const RecipeOutput*> eligible;
|
||
std::vector<double> weights;
|
||
for (const RecipeOutput& out : recipe.outputs)
|
||
{
|
||
if (!m_isItemUnlocked(out.item)) { continue; }
|
||
eligible.push_back(&out);
|
||
weights.push_back(out.probability.value_or(1.0));
|
||
}
|
||
|
||
if (eligible.empty()) { return {}; }
|
||
|
||
std::discrete_distribution<int> dist(weights.begin(), weights.end());
|
||
const RecipeOutput& chosen = *eligible[static_cast<std::size_t>(dist(m_rng))];
|
||
std::vector<Item> result;
|
||
Item item;
|
||
item.type.id = chosen.item;
|
||
for (int i = 0; i < chosen.amount; ++i)
|
||
{
|
||
result.push_back(item);
|
||
}
|
||
return result;
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Placement
|
||
// ---------------------------------------------------------------------------
|
||
|
||
std::optional<BuildingId> BuildingSystem::place(BuildingType type, QPoint anchor,
|
||
Rotation rotation, Tick currentTick)
|
||
{
|
||
const BuildingDef* def = m_config.buildings.findBuildingDef(type);
|
||
assert(def != nullptr);
|
||
const ParsedSurfaceMask mask = parseSurfaceMask(def->surfaceMask, rotation);
|
||
|
||
// Reject placements that fall outside the world (REQ-BLD-PLACE-VALID).
|
||
if (!bodyCellsWithinWorldBounds(mask.bodyCells, anchor))
|
||
{
|
||
return std::nullopt;
|
||
}
|
||
|
||
const BuildingId id = m_allocateBuildingId();
|
||
|
||
// Record tile occupancy for body cells.
|
||
for (const QPoint& cell : mask.bodyCells)
|
||
{
|
||
const QPoint absCell = anchor + cell;
|
||
m_tileOccupancy[{absCell.x(), absCell.y()}] = id;
|
||
}
|
||
|
||
// Build construction site.
|
||
ConstructionSite site;
|
||
site.id = id;
|
||
site.anchor = anchor;
|
||
site.footprint = mask.footprint;
|
||
site.rotation = rotation;
|
||
site.type = type;
|
||
for (const QPoint& cell : mask.bodyCells)
|
||
{
|
||
site.bodyCells.push_back(anchor + cell);
|
||
}
|
||
|
||
if (m_constructionQueue.empty())
|
||
{
|
||
site.completesAt = currentTick + secondsToTicks(def->constructionTimeSeconds);
|
||
}
|
||
// else: completesAt remains 0 (queued, not yet started).
|
||
|
||
m_constructionQueue.push_back(std::move(site));
|
||
return id;
|
||
}
|
||
|
||
bool BuildingSystem::bodyCellsWithinWorldBounds(const std::vector<QPoint>& bodyCells,
|
||
QPoint anchor) const
|
||
{
|
||
const int heightTiles = m_config.world.heightTiles;
|
||
const int leftEdgeX = -m_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
|
||
// ---------------------------------------------------------------------------
|
||
|
||
int BuildingSystem::deconstruct(BuildingId id, Tick currentTick)
|
||
{
|
||
// Construction site? Removed instantly with the full refund; never queued
|
||
// for deconstruction (REQ-BLD-DECONSTRUCT).
|
||
for (std::deque<ConstructionSite>::iterator it = m_constructionQueue.begin();
|
||
it != m_constructionQueue.end();
|
||
++it)
|
||
{
|
||
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_constructionQueue.erase(it);
|
||
if (def)
|
||
{
|
||
return def->cost;
|
||
}
|
||
return 0;
|
||
}
|
||
}
|
||
|
||
// Operational building? Append it to the deconstruction queue rather than
|
||
// removing it now; the partial refund is credited on completion in
|
||
// tickDeconstruction (REQ-BLD-DECON-QUEUE).
|
||
for (Building& building : m_buildings)
|
||
{
|
||
if (building.id != id) { continue; }
|
||
if (building.queuedForDeconstruction) { return 0; } // already queued
|
||
|
||
building.queuedForDeconstruction = true;
|
||
|
||
DeconstructionEntry entry;
|
||
entry.id = id;
|
||
|
||
// A queued belt/tunnel/splitter stops transporting at once: capture a
|
||
// splitter's filters (so an un-queue can restore them), then unregister
|
||
// its tile, discarding items on it and re-pairing tunnels as if it were
|
||
// gone (REQ-BLD-TUNNEL-PAIR).
|
||
if (building.type == BuildingType::Splitter)
|
||
{
|
||
if (const std::optional<BeltSystem::SplitterInfo> info =
|
||
m_belts.getSplitterInfo(building.anchor))
|
||
{
|
||
entry.splitterFilterA = info->filterA;
|
||
entry.splitterFilterB = info->filterB;
|
||
}
|
||
}
|
||
if (isBeltSubsystemType(building.type))
|
||
{
|
||
m_belts.removeTile(building.anchor);
|
||
}
|
||
|
||
const bool wasEmpty = m_deconstructionQueue.empty();
|
||
m_deconstructionQueue.push_back(std::move(entry));
|
||
if (wasEmpty)
|
||
{
|
||
startFrontDeconstruction(currentTick);
|
||
}
|
||
return 0;
|
||
}
|
||
|
||
return 0;
|
||
}
|
||
|
||
void BuildingSystem::startFrontDeconstruction(Tick currentTick)
|
||
{
|
||
if (m_deconstructionQueue.empty()) { return; }
|
||
DeconstructionEntry& front = m_deconstructionQueue.front();
|
||
if (front.completesAt == 0)
|
||
{
|
||
front.completesAt =
|
||
currentTick + secondsToTicks(m_config.world.deconstructionTimeSeconds);
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Set recipe
|
||
// ---------------------------------------------------------------------------
|
||
|
||
void BuildingSystem::setRecipe(BuildingId id, const std::string& recipeId)
|
||
{
|
||
// Construction site: store recipe for when building completes.
|
||
for (ConstructionSite& site : m_constructionQueue)
|
||
{
|
||
if (site.id == id)
|
||
{
|
||
// Auto-recipe buildings have no player-selected recipe
|
||
// (REQ-BLD-SMELTER, REQ-BLD-REPROCESSING); ignore any attempt to set one.
|
||
if (isAutoRecipeBuildingType(site.type))
|
||
{
|
||
return;
|
||
}
|
||
// No-op if the recipe is unchanged, so a redundant selection does
|
||
// not wipe an already-configured ship layout.
|
||
if (site.recipeId == recipeId)
|
||
{
|
||
return;
|
||
}
|
||
site.recipeId = recipeId;
|
||
site.shipLayout = std::nullopt;
|
||
return;
|
||
}
|
||
}
|
||
|
||
// Operational building: clear buffers and re-init.
|
||
for (Building& building : m_buildings)
|
||
{
|
||
if (building.id == id)
|
||
{
|
||
// Auto-recipe buildings have no player-selected recipe
|
||
// (REQ-BLD-SMELTER, REQ-BLD-REPROCESSING); ignore any attempt to set one.
|
||
if (isAutoRecipeBuildingType(building.type))
|
||
{
|
||
return;
|
||
}
|
||
// No-op if the recipe is unchanged, so a redundant selection does
|
||
// not wipe an already-configured ship layout or reset buffers.
|
||
if (building.recipeId == recipeId)
|
||
{
|
||
return;
|
||
}
|
||
building.recipeId = recipeId;
|
||
building.shipLayout = std::nullopt;
|
||
building.inputBuffer.counts.clear();
|
||
building.inputBuffer.caps.clear();
|
||
building.outputBuffer.items.clear();
|
||
building.outputBuffer.capacity = 0;
|
||
// Emerging items are part of the output buffer, so clearing it on a
|
||
// recipe change discards them too (REQ-MAT-OUTPUT-EMERGE); in-transit
|
||
// input items are discarded and their reservations released
|
||
// (REQ-MAT-INPUT-INTAKE).
|
||
for (std::vector<BeltItemSlot>& lane : building.emergingItems) { lane.clear(); }
|
||
for (std::vector<BeltItemSlot>& lane : building.incomingItems) { lane.clear(); }
|
||
building.production = std::nullopt;
|
||
|
||
if (!recipeId.empty())
|
||
{
|
||
if (building.type == BuildingType::Shipyard)
|
||
{
|
||
initShipyardBuffers(building);
|
||
}
|
||
else
|
||
{
|
||
const RecipeDef* recipe = m_config.recipes.findRecipeDef(recipeId, building.type);
|
||
if (recipe)
|
||
{
|
||
initBuffers(building, *recipe);
|
||
}
|
||
}
|
||
}
|
||
return;
|
||
}
|
||
}
|
||
}
|
||
|
||
void BuildingSystem::setShipLayout(BuildingId id, const ShipLayoutConfig& layout)
|
||
{
|
||
for (ConstructionSite& site : m_constructionQueue)
|
||
{
|
||
if (site.id == id)
|
||
{
|
||
site.shipLayout = layout;
|
||
return;
|
||
}
|
||
}
|
||
|
||
for (Building& building : m_buildings)
|
||
{
|
||
if (building.id == id)
|
||
{
|
||
if (building.production.has_value())
|
||
{
|
||
building.production = std::nullopt;
|
||
}
|
||
building.shipLayout = layout;
|
||
building.inputBuffer.counts.clear();
|
||
building.inputBuffer.caps.clear();
|
||
building.outputBuffer.items.clear();
|
||
building.outputBuffer.capacity = 0;
|
||
for (std::vector<BeltItemSlot>& lane : building.emergingItems) { lane.clear(); }
|
||
for (std::vector<BeltItemSlot>& lane : building.incomingItems) { lane.clear(); }
|
||
if (!building.recipeId.empty() && building.type == BuildingType::Shipyard)
|
||
{
|
||
initShipyardBuffers(building);
|
||
}
|
||
return;
|
||
}
|
||
}
|
||
}
|
||
|
||
std::optional<BeltSystem::SplitterInfo>
|
||
BuildingSystem::getSiteSplitterInfo(BuildingId id) const
|
||
{
|
||
for (const ConstructionSite& site : m_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)
|
||
{
|
||
for (ConstructionSite& site : m_constructionQueue)
|
||
{
|
||
if (site.id == id && site.type == BuildingType::Splitter)
|
||
{
|
||
site.splitterFilterA = filterA;
|
||
site.splitterFilterB = filterB;
|
||
return;
|
||
}
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Tick hooks
|
||
// ---------------------------------------------------------------------------
|
||
|
||
void BuildingSystem::tickConstruction(Tick currentTick)
|
||
{
|
||
TRACE();
|
||
if (m_constructionQueue.empty())
|
||
{
|
||
return;
|
||
}
|
||
|
||
ConstructionSite& front = m_constructionQueue.front();
|
||
|
||
// Guard: if somehow the front site was never started, start it now.
|
||
if (front.completesAt == 0)
|
||
{
|
||
const BuildingDef* def = m_config.buildings.findBuildingDef(front.type);
|
||
if (def)
|
||
{
|
||
front.completesAt = currentTick + secondsToTicks(def->constructionTimeSeconds);
|
||
}
|
||
return;
|
||
}
|
||
|
||
if (currentTick < front.completesAt)
|
||
{
|
||
return;
|
||
}
|
||
|
||
// Promote construction site to an operational Building.
|
||
const BuildingDef* def = m_config.buildings.findBuildingDef(front.type);
|
||
const ParsedSurfaceMask mask = parseSurfaceMask(
|
||
def ? def->surfaceMask : std::vector<std::string>{},
|
||
front.rotation);
|
||
|
||
Building building;
|
||
building.id = front.id;
|
||
building.anchor = front.anchor;
|
||
building.footprint = front.footprint;
|
||
building.rotation = front.rotation;
|
||
building.type = front.type;
|
||
building.recipeId = front.recipeId;
|
||
building.shipLayout = front.shipLayout;
|
||
|
||
for (const QPoint& cell : mask.bodyCells)
|
||
{
|
||
building.bodyCells.push_back(front.anchor + cell);
|
||
}
|
||
for (const Port& port : mask.outputPorts)
|
||
{
|
||
Port absPort;
|
||
absPort.tile = front.anchor + port.tile;
|
||
absPort.direction = port.direction;
|
||
building.outputPorts.push_back(absPort);
|
||
}
|
||
building.emergingItems.resize(building.outputPorts.size());
|
||
building.inputPorts = computeInputPorts(building);
|
||
building.incomingItems.assign(building.inputPorts.size(), {});
|
||
|
||
if (building.type == BuildingType::SalvageBay)
|
||
{
|
||
initSalvageBayBuffer(building);
|
||
}
|
||
else if (isAutoRecipeBuildingType(building.type))
|
||
{
|
||
// Smelter/Reprocessing Plant need no recipe selection; buffers are set
|
||
// up from all recipes of the type (REQ-BLD-SMELTER, REQ-BLD-REPROCESSING).
|
||
initAutoBuffers(building);
|
||
}
|
||
else if (!building.recipeId.empty())
|
||
{
|
||
if (building.type == BuildingType::Shipyard)
|
||
{
|
||
initShipyardBuffers(building);
|
||
}
|
||
else
|
||
{
|
||
const RecipeDef* recipe = m_config.recipes.findRecipeDef(building.recipeId, building.type);
|
||
if (recipe)
|
||
{
|
||
initBuffers(building, *recipe);
|
||
}
|
||
}
|
||
}
|
||
|
||
// Register with BeltSystem before the move (mask/building stays valid). Any
|
||
// filters configured while under construction carry over (REQ-BLD-SITE-CONFIG).
|
||
reregisterBeltTile(building, front.splitterFilterA, front.splitterFilterB);
|
||
|
||
m_buildings.push_back(std::move(building));
|
||
|
||
m_constructionQueue.pop_front();
|
||
|
||
// Start next queued site if present.
|
||
if (!m_constructionQueue.empty() && m_constructionQueue.front().completesAt == 0)
|
||
{
|
||
const BuildingDef* nextDef =
|
||
m_config.buildings.findBuildingDef(m_constructionQueue.front().type);
|
||
if (nextDef)
|
||
{
|
||
m_constructionQueue.front().completesAt =
|
||
currentTick + secondsToTicks(nextDef->constructionTimeSeconds);
|
||
}
|
||
}
|
||
}
|
||
|
||
void BuildingSystem::reregisterBeltTile(const Building& building,
|
||
const std::vector<ItemType>& splitterFilterA,
|
||
const std::vector<ItemType>& splitterFilterB)
|
||
{
|
||
switch (building.type)
|
||
{
|
||
case BuildingType::Belt:
|
||
m_belts.placeBelt(building.anchor, building.rotation);
|
||
break;
|
||
case BuildingType::Splitter:
|
||
assert(building.outputPorts.size() >= 2);
|
||
m_belts.placeSplitter(building.anchor,
|
||
building.outputPorts[0].direction,
|
||
building.outputPorts[1].direction);
|
||
m_belts.setSplitterFilters(building.anchor, splitterFilterA, splitterFilterB);
|
||
break;
|
||
case BuildingType::TunnelEntry:
|
||
m_belts.placeTunnelEntry(building.anchor, building.rotation,
|
||
m_config.world.tunnelMaxDistance_tiles);
|
||
break;
|
||
case BuildingType::TunnelExit:
|
||
m_belts.placeTunnelExit(building.anchor, building.rotation);
|
||
break;
|
||
default:
|
||
break;
|
||
}
|
||
}
|
||
|
||
void BuildingSystem::tickDeconstruction(Tick currentTick)
|
||
{
|
||
TRACE();
|
||
if (m_deconstructionQueue.empty())
|
||
{
|
||
return;
|
||
}
|
||
|
||
DeconstructionEntry& front = m_deconstructionQueue.front();
|
||
|
||
// Guard: if the front entry's timer was never started, start it now.
|
||
if (front.completesAt == 0)
|
||
{
|
||
startFrontDeconstruction(currentTick);
|
||
return;
|
||
}
|
||
|
||
if (currentTick < front.completesAt)
|
||
{
|
||
return;
|
||
}
|
||
|
||
// Remove the building from the world and credit its refund (REQ-BLD-DECONSTRUCT).
|
||
// Belt/tunnel/splitter tiles were already unregistered when the building was
|
||
// queued (see deconstruct), so only tile occupancy and the record remain.
|
||
for (std::vector<Building>::iterator it = m_buildings.begin();
|
||
it != m_buildings.end();
|
||
++it)
|
||
{
|
||
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_buildings.erase(it);
|
||
if (def)
|
||
{
|
||
m_addBuildingBlocks(def->cost * m_config.world.refundPercentage / 100);
|
||
}
|
||
break;
|
||
}
|
||
|
||
m_deconstructionQueue.pop_front();
|
||
|
||
// Start the next queued deconstruction, if any.
|
||
startFrontDeconstruction(currentTick);
|
||
}
|
||
|
||
void BuildingSystem::cancelDeconstruction(BuildingId id)
|
||
{
|
||
for (std::deque<DeconstructionEntry>::iterator it = m_deconstructionQueue.begin();
|
||
it != m_deconstructionQueue.end();
|
||
++it)
|
||
{
|
||
if (it->id != id) { continue; }
|
||
|
||
// Resume operation: clear the flag and re-register belt/tunnel/splitter
|
||
// tiles that were unregistered at enqueue (which re-pairs tunnels,
|
||
// REQ-BLD-TUNNEL-PAIR). Deconstruction progress is discarded; no refund.
|
||
if (Building* building = findBuildingMutable(id))
|
||
{
|
||
building->queuedForDeconstruction = false;
|
||
reregisterBeltTile(*building, it->splitterFilterA, it->splitterFilterB);
|
||
}
|
||
|
||
m_deconstructionQueue.erase(it);
|
||
// If the running front was removed, the new front (completesAt == 0) has
|
||
// its timer started by the next tickDeconstruction guard.
|
||
return;
|
||
}
|
||
}
|
||
|
||
bool BuildingSystem::isQueuedForDeconstruction(BuildingId id) const
|
||
{
|
||
const Building* building = findBuilding(id);
|
||
return building && building->queuedForDeconstruction;
|
||
}
|
||
|
||
void BuildingSystem::tickBeltPull()
|
||
{
|
||
TRACE();
|
||
// Same per-tick step as the belts, so items travel inward at belt speed
|
||
// (REQ-GW-BELT-SPEED, REQ-MAT-INPUT-INTAKE).
|
||
const double progressPerTick = m_belts.getProgressPerTick_tpt();
|
||
|
||
for (Building& building : m_buildings)
|
||
{
|
||
// A building queued for deconstruction stops operating (REQ-BLD-DECON-QUEUE).
|
||
if (building.queuedForDeconstruction) { continue; }
|
||
|
||
const bool isHq = (building.type == BuildingType::Hq);
|
||
|
||
// 1. Advance every input belt and deliver arrivals (progress >= 0.5) into
|
||
// the input buffer — or the global stock for the HQ. Runs for all
|
||
// buildings so in-transit items keep moving even when feeding is gated
|
||
// off, and arrivals become consumable before tickProduction (step 4).
|
||
for (std::size_t i = 0; i < building.incomingItems.size(); ++i)
|
||
{
|
||
std::vector<BeltItemSlot>& lane = building.incomingItems[i];
|
||
advanceBeltSlots(lane, progressPerTick);
|
||
while (!lane.empty() && lane.front().progress >= 0.5)
|
||
{
|
||
const Item arrived = lane.front().item;
|
||
lane.erase(lane.begin());
|
||
if (isHq)
|
||
{
|
||
m_addBuildingBlocks(1);
|
||
}
|
||
else
|
||
{
|
||
building.inputBuffer.counts[arrived.type]++;
|
||
}
|
||
}
|
||
}
|
||
|
||
// 2. Feed accepted items from adjacent belts onto the input belts at
|
||
// progress 0.0. The acceptance rules — the HQ building-block case, the
|
||
// required-input check, and the reservation — live in canAcceptInput so
|
||
// direct coupling (REQ-MAT-DIRECT-COUPLE) shares them exactly.
|
||
for (std::size_t i = 0; i < building.inputPorts.size(); ++i)
|
||
{
|
||
const std::optional<ItemType> peeked = m_belts.peekItem(building.inputPorts[i]);
|
||
if (!peeked) { continue; }
|
||
if (!canAcceptInput(building, i, *peeked)) { continue; }
|
||
const std::optional<Item> taken = m_belts.tryTakeItem(building.inputPorts[i]);
|
||
if (taken)
|
||
{
|
||
depositToInputBelt(building, i, *taken);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
bool BuildingSystem::canAcceptInput(const Building& consumer,
|
||
std::size_t inputPortIndex,
|
||
const ItemType& type) const
|
||
{
|
||
if (inputPortIndex >= consumer.incomingItems.size()) { return false; }
|
||
if (!inputLaneEntryFree(consumer.incomingItems[inputPortIndex])) { return false; }
|
||
|
||
// The HQ has no input buffer; it accepts building blocks into the global stock
|
||
// (REQ-HQ-BELT-INPUT) with no reservation.
|
||
if (consumer.type == BuildingType::Hq)
|
||
{
|
||
return type.id == "building_block";
|
||
}
|
||
|
||
// Everyone else: the item must be a required input whose reservation-aware
|
||
// buffer has room — buffered + in-transit below the cap (REQ-MAT-INPUT-INTAKE).
|
||
const std::map<ItemType, int>::const_iterator capIt =
|
||
consumer.inputBuffer.caps.find(type);
|
||
if (capIt == consumer.inputBuffer.caps.end() || capIt->second == 0)
|
||
{
|
||
return false;
|
||
}
|
||
return consumer.pendingInputCount(type) < capIt->second;
|
||
}
|
||
|
||
void BuildingSystem::depositToInputBelt(Building& consumer,
|
||
std::size_t inputPortIndex,
|
||
const Item& item)
|
||
{
|
||
consumer.incomingItems[inputPortIndex].push_back(BeltItemSlot{item, 0.0});
|
||
}
|
||
|
||
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)
|
||
{
|
||
return false;
|
||
}
|
||
|
||
Building* consumer = findBuildingMutable(occIt->second);
|
||
if (!consumer)
|
||
{
|
||
return false; // an unbuilt construction site, or not an operational building
|
||
}
|
||
if (consumer->queuedForDeconstruction)
|
||
{
|
||
return false; // queued for deconstruction: stopped operating (REQ-BLD-DECON-QUEUE)
|
||
}
|
||
|
||
// The coupling is the consumer input port meeting this output port: same flow
|
||
// direction, feeding the producer's output-port tile (REQ-MAT-DIRECT-COUPLE).
|
||
for (std::size_t j = 0; j < consumer->inputPorts.size(); ++j)
|
||
{
|
||
const Port& in = consumer->inputPorts[j];
|
||
if (in.direction != outputPort.direction) { continue; }
|
||
if (inputBodyTile(in.tile, in.direction) != outputPort.tile) { continue; }
|
||
|
||
if (!canAcceptInput(*consumer, j, item.type)) { return false; }
|
||
depositToInputBelt(*consumer, j, item);
|
||
return true;
|
||
}
|
||
return false;
|
||
}
|
||
|
||
void BuildingSystem::tickProduction(Tick currentTick)
|
||
{
|
||
TRACE();
|
||
for (Building& building : m_buildings)
|
||
{
|
||
// A building queued for deconstruction stops operating (REQ-BLD-DECON-QUEUE).
|
||
if (building.queuedForDeconstruction) { continue; }
|
||
|
||
// Skip types without a recipe-based production loop.
|
||
if (building.type == BuildingType::Belt ||
|
||
building.type == BuildingType::Splitter ||
|
||
building.type == BuildingType::Shipyard ||
|
||
building.type == BuildingType::SalvageBay ||
|
||
building.type == BuildingType::Hq)
|
||
{
|
||
continue;
|
||
}
|
||
|
||
const bool autoRecipe = isAutoRecipeBuildingType(building.type);
|
||
if (!autoRecipe && building.recipeId.empty())
|
||
{
|
||
continue;
|
||
}
|
||
|
||
// If a production cycle is active, check for completion. Completion only
|
||
// needs the already-decided outputs, so it does not depend on which
|
||
// recipe is selected or auto-chosen.
|
||
if (building.production)
|
||
{
|
||
if (currentTick >= building.production->completesAt)
|
||
{
|
||
for (const Item& item : building.production->chosenOutputs)
|
||
{
|
||
building.outputBuffer.items.push_back(item);
|
||
}
|
||
building.production = std::nullopt;
|
||
}
|
||
// Whether we just completed or are still running, do not start
|
||
// another cycle in the same tick.
|
||
continue;
|
||
}
|
||
|
||
// Idle: gather the candidate recipes to try. Auto-recipe buildings
|
||
// (Smelter, Reprocessing Plant) have no selected recipe and try every
|
||
// recipe of their type in config order, running the first whose inputs
|
||
// are satisfied (REQ-BLD-SMELTER, REQ-BLD-REPROCESSING). Other buildings
|
||
// try only their selected recipe.
|
||
const std::vector<const RecipeDef*> candidates =
|
||
gatherCandidateRecipes(building);
|
||
|
||
for (const RecipeDef* recipe : candidates)
|
||
{
|
||
// 1. All required inputs present?
|
||
if (!recipeInputsAvailable(building, *recipe))
|
||
{
|
||
continue;
|
||
}
|
||
|
||
// 2. Determine chosen outputs (roll for reprocessing).
|
||
std::vector<Item> chosen;
|
||
if (building.type == BuildingType::ReprocessingPlant)
|
||
{
|
||
chosen = rollReprocessingOutput(*recipe);
|
||
if (chosen.empty()) { continue; }
|
||
}
|
||
else
|
||
{
|
||
for (const RecipeOutput& out : recipe->outputs)
|
||
{
|
||
Item item;
|
||
item.type.id = out.item;
|
||
for (int i = 0; i < out.amount; ++i)
|
||
{
|
||
chosen.push_back(item);
|
||
}
|
||
}
|
||
}
|
||
|
||
// 3. Output buffer has space for chosen outputs? Emerging items still
|
||
// count against the buffer (REQ-MAT-OUTPUT-EMERGE).
|
||
const int newSize = building.getOutputItemCount()
|
||
+ static_cast<int>(chosen.size());
|
||
if (newSize > building.outputBuffer.capacity)
|
||
{
|
||
continue;
|
||
}
|
||
|
||
// 4. Consume inputs and start cycle.
|
||
for (const RecipeIngredient& ing : recipe->inputs)
|
||
{
|
||
building.inputBuffer.counts[ItemType{ing.item}] -= ing.amount;
|
||
}
|
||
|
||
Production prod;
|
||
prod.recipeId = recipe->id;
|
||
prod.completesAt = currentTick + secondsToTicks(recipe->durationSeconds);
|
||
prod.chosenOutputs = std::move(chosen);
|
||
building.production = std::move(prod);
|
||
break; // At most one cycle starts per tick.
|
||
}
|
||
}
|
||
}
|
||
|
||
void BuildingSystem::tickShipyardProduction(Tick currentTick)
|
||
{
|
||
TRACE();
|
||
for (Building& building : m_buildings)
|
||
{
|
||
// A building queued for deconstruction stops operating (REQ-BLD-DECON-QUEUE).
|
||
if (building.queuedForDeconstruction) { continue; }
|
||
|
||
if (building.type != BuildingType::Shipyard)
|
||
{
|
||
continue;
|
||
}
|
||
if (building.recipeId.empty())
|
||
{
|
||
continue;
|
||
}
|
||
const ShipDef* shipDef = m_config.ships.findShipDef(building.recipeId);
|
||
if (!shipDef)
|
||
{
|
||
continue;
|
||
}
|
||
|
||
// If a cycle is in progress, check for completion.
|
||
if (building.production)
|
||
{
|
||
if (currentTick >= building.production->completesAt)
|
||
{
|
||
if (!building.outputPorts.empty())
|
||
{
|
||
const Port& p = building.outputPorts[0];
|
||
const QVector2D spawnPos(p.tile.x() + 0.5f, p.tile.y() + 0.5f);
|
||
// A shipyard builds exactly what the player configured and
|
||
// paid for. When no layout is set it produces a bare hull, so
|
||
// pass an explicit empty layout rather than nullopt: the latter
|
||
// would make ShipSystem fall back to the schematic's
|
||
// defaultModules (a wave-only loadout) and yield free weapons.
|
||
const std::optional<ShipLayoutConfig> layout =
|
||
building.shipLayout.has_value()
|
||
? building.shipLayout
|
||
: std::make_optional<ShipLayoutConfig>();
|
||
m_spawnShip(building.recipeId, spawnPos, layout);
|
||
}
|
||
building.production = std::nullopt;
|
||
}
|
||
continue;
|
||
}
|
||
|
||
// Build combined materials list (base + modules).
|
||
const std::map<std::string, int> requiredMaterials =
|
||
computeShipyardRequiredMaterials(building);
|
||
|
||
// Idle: check if all combined materials are available.
|
||
bool inputsOk = true;
|
||
for (const std::pair<const std::string, int>& req : requiredMaterials)
|
||
{
|
||
const ItemType type{req.first};
|
||
const std::map<ItemType, int>::const_iterator it =
|
||
building.inputBuffer.counts.find(type);
|
||
const int have = (it != building.inputBuffer.counts.end()) ? it->second : 0;
|
||
if (have < req.second)
|
||
{
|
||
inputsOk = false;
|
||
break;
|
||
}
|
||
}
|
||
if (!inputsOk)
|
||
{
|
||
continue;
|
||
}
|
||
|
||
// Consume combined materials and start the production cycle.
|
||
for (const std::pair<const std::string, int>& req : requiredMaterials)
|
||
{
|
||
building.inputBuffer.counts[ItemType{req.first}] -= req.second;
|
||
}
|
||
|
||
double totalTime = shipDef->schematic.productionTimeSeconds;
|
||
if (building.shipLayout.has_value())
|
||
{
|
||
for (const PlacedModule& pm : building.shipLayout->placedModules)
|
||
{
|
||
const ModuleDef* modDef = m_config.modules.findModuleDef(pm.moduleId);
|
||
if (modDef)
|
||
{
|
||
totalTime += modDef->productionTimeSeconds;
|
||
}
|
||
}
|
||
}
|
||
|
||
Production prod;
|
||
prod.recipeId = building.recipeId;
|
||
prod.completesAt = currentTick + secondsToTicks(totalTime);
|
||
building.production = std::move(prod);
|
||
}
|
||
}
|
||
|
||
void BuildingSystem::tickOutputBelts()
|
||
{
|
||
TRACE();
|
||
// Use BeltSystem's own per-tick step so emerging items travel at exactly the
|
||
// same speed as real belts (REQ-GW-BELT-SPEED, REQ-MAT-OUTPUT-EMERGE).
|
||
const double progressPerTick = m_belts.getProgressPerTick_tpt();
|
||
|
||
for (Building& building : m_buildings)
|
||
{
|
||
// A building queued for deconstruction stops operating (REQ-BLD-DECON-QUEUE).
|
||
if (building.queuedForDeconstruction) { continue; }
|
||
|
||
for (std::size_t p = 0; p < building.outputPorts.size(); ++p)
|
||
{
|
||
const Port& port = building.outputPorts[p];
|
||
std::vector<BeltItemSlot>& lane = building.emergingItems[p];
|
||
|
||
// 1. Advance emerging items using the shared belt packing (progress
|
||
// caps to 0.5 / 0.75 / 1.0 for up to three items).
|
||
advanceBeltSlots(lane, progressPerTick);
|
||
|
||
// 2. Hand the front item off once it reaches the output edge (progress
|
||
// 1.0): onto the adjacent real belt, or — if a building's input edge
|
||
// meets this port — straight into that building (REQ-MAT-DIRECT-COUPLE).
|
||
// On refusal (no belt/coupling, output-edge per REQ-MAT-ACCEPT-DIR, or
|
||
// a full target) it stays stuck at 1.0.
|
||
if (!lane.empty() && lane.front().progress >= 1.0)
|
||
{
|
||
const Item item = lane.front().item;
|
||
if (m_belts.tryPutItem(port.tile, item, port.direction)
|
||
|| tryDirectCoupleDeposit(building.id, port, item))
|
||
{
|
||
lane.erase(lane.begin());
|
||
}
|
||
}
|
||
|
||
// 3. Feed the next buffered item onto the lane at progress 0.5 when the
|
||
// entry slot is free — the lane holds at most three items and a new
|
||
// one needs a quarter-tile clearance ahead of 0.5.
|
||
if (!building.outputBuffer.items.empty()
|
||
&& lane.size() < 3
|
||
&& (lane.empty() || lane.back().progress >= 0.75))
|
||
{
|
||
lane.push_back(BeltItemSlot{building.outputBuffer.items.front(), 0.5});
|
||
building.outputBuffer.items.erase(building.outputBuffer.items.begin());
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
void BuildingSystem::forEachEmergingItem(
|
||
const std::function<void(const ItemType&, QPointF)>& visit) const
|
||
{
|
||
for (const Building& building : m_buildings)
|
||
{
|
||
for (std::size_t p = 0; p < building.outputPorts.size(); ++p)
|
||
{
|
||
const Port& port = building.outputPorts[p];
|
||
const QPoint bodyTile = outputBodyTile(port.tile, port.direction);
|
||
const std::vector<BeltItemSlot>& lane = building.emergingItems[p];
|
||
|
||
// Render least-progressed first (bottom) → most-progressed last (top),
|
||
// matching belt item ordering (REQ-GW-TILE-SIZE).
|
||
for (int i = static_cast<int>(lane.size()) - 1; i >= 0; --i)
|
||
{
|
||
visit(lane[i].item.type,
|
||
beltSlotWorldPos(bodyTile, port.direction, lane[i].progress));
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
void BuildingSystem::forEachIncomingItem(
|
||
const std::function<void(const ItemType&, QPointF)>& visit) const
|
||
{
|
||
for (const Building& building : m_buildings)
|
||
{
|
||
for (std::size_t p = 0; p < building.inputPorts.size(); ++p)
|
||
{
|
||
const Port& port = building.inputPorts[p];
|
||
const QPoint bodyTile = inputBodyTile(port.tile, port.direction);
|
||
const std::vector<BeltItemSlot>& lane = building.incomingItems[p];
|
||
|
||
// Render least-progressed first (bottom) → most-progressed last (top),
|
||
// matching belt item ordering (REQ-GW-TILE-SIZE).
|
||
for (int i = static_cast<int>(lane.size()) - 1; i >= 0; --i)
|
||
{
|
||
visit(lane[i].item.type,
|
||
beltSlotWorldPos(bodyTile, port.direction, lane[i].progress));
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Queries
|
||
// ---------------------------------------------------------------------------
|
||
|
||
const Building* BuildingSystem::findBuilding(BuildingId id) const
|
||
{
|
||
for (const Building& building : m_buildings)
|
||
{
|
||
if (building.id == id)
|
||
{
|
||
return &building;
|
||
}
|
||
}
|
||
return nullptr;
|
||
}
|
||
|
||
Building* BuildingSystem::findBuildingMutable(BuildingId id)
|
||
{
|
||
for (Building& building : m_buildings)
|
||
{
|
||
if (building.id == id)
|
||
{
|
||
return &building;
|
||
}
|
||
}
|
||
return nullptr;
|
||
}
|
||
|
||
const ConstructionSite* BuildingSystem::findSite(BuildingId id) const
|
||
{
|
||
for (const ConstructionSite& site : m_constructionQueue)
|
||
{
|
||
if (site.id == id)
|
||
{
|
||
return &site;
|
||
}
|
||
}
|
||
return nullptr;
|
||
}
|
||
|
||
std::vector<Building> BuildingSystem::getAllBuildings() const
|
||
{
|
||
return m_buildings;
|
||
}
|
||
|
||
std::vector<ConstructionSite> BuildingSystem::getAllSites() const
|
||
{
|
||
return std::vector<ConstructionSite>(m_constructionQueue.begin(),
|
||
m_constructionQueue.end());
|
||
}
|
||
|
||
namespace
|
||
{
|
||
bool isProductionBuildingType(BuildingType type)
|
||
{
|
||
switch (type)
|
||
{
|
||
case BuildingType::Miner:
|
||
case BuildingType::Smelter:
|
||
case BuildingType::Assembler:
|
||
case BuildingType::ReprocessingPlant:
|
||
case BuildingType::Shipyard:
|
||
return true;
|
||
default:
|
||
return false;
|
||
}
|
||
}
|
||
} // namespace
|
||
|
||
int BuildingSystem::getProductionBuildingCount() const
|
||
{
|
||
int count = 0;
|
||
for (const Building& b : m_buildings)
|
||
{
|
||
if (isProductionBuildingType(b.type)) { ++count; }
|
||
}
|
||
return count;
|
||
}
|
||
|
||
int BuildingSystem::getActiveProductionBuildingCount() const
|
||
{
|
||
int count = 0;
|
||
for (const Building& b : m_buildings)
|
||
{
|
||
if (isProductionBuildingType(b.type) && b.production.has_value()) { ++count; }
|
||
}
|
||
return count;
|
||
}
|
||
|
||
std::vector<const RecipeDef*>
|
||
BuildingSystem::gatherCandidateRecipes(const Building& b) const
|
||
{
|
||
std::vector<const RecipeDef*> candidates;
|
||
if (isAutoRecipeBuildingType(b.type))
|
||
{
|
||
for (const RecipeDef& r : m_config.recipes.recipes)
|
||
{
|
||
if (r.building == b.type && !r.inputs.empty())
|
||
{
|
||
candidates.push_back(&r);
|
||
}
|
||
}
|
||
}
|
||
else
|
||
{
|
||
const RecipeDef* recipe = m_config.recipes.findRecipeDef(b.recipeId, b.type);
|
||
if (recipe)
|
||
{
|
||
candidates.push_back(recipe);
|
||
}
|
||
}
|
||
return candidates;
|
||
}
|
||
|
||
bool BuildingSystem::recipeInputsAvailable(const Building& b,
|
||
const RecipeDef& recipe) const
|
||
{
|
||
for (const RecipeIngredient& ing : recipe.inputs)
|
||
{
|
||
const std::map<ItemType, int>::const_iterator it =
|
||
b.inputBuffer.counts.find(ItemType{ing.item});
|
||
const int have = (it != b.inputBuffer.counts.end()) ? it->second : 0;
|
||
if (have < ing.amount)
|
||
{
|
||
return false;
|
||
}
|
||
}
|
||
return true;
|
||
}
|
||
|
||
std::map<std::string, int>
|
||
BuildingSystem::computeShipyardRequiredMaterials(const Building& b) const
|
||
{
|
||
std::map<std::string, int> requiredMaterials;
|
||
const ShipDef* shipDef = m_config.ships.findShipDef(b.recipeId);
|
||
if (!shipDef)
|
||
{
|
||
return requiredMaterials;
|
||
}
|
||
for (const RecipeIngredient& ing : shipDef->schematic.materials)
|
||
{
|
||
requiredMaterials[ing.item] += ing.amount;
|
||
}
|
||
if (b.shipLayout.has_value())
|
||
{
|
||
for (const PlacedModule& pm : b.shipLayout->placedModules)
|
||
{
|
||
const ModuleDef* modDef = m_config.modules.findModuleDef(pm.moduleId);
|
||
if (!modDef)
|
||
{
|
||
continue;
|
||
}
|
||
for (const RecipeIngredient& ing : modDef->materials)
|
||
{
|
||
requiredMaterials[ing.item] += ing.amount;
|
||
}
|
||
}
|
||
}
|
||
return requiredMaterials;
|
||
}
|
||
|
||
bool BuildingSystem::hasInputsToStart(const Building& b) const
|
||
{
|
||
if (b.type == BuildingType::Shipyard)
|
||
{
|
||
const std::map<std::string, int> required =
|
||
computeShipyardRequiredMaterials(b);
|
||
for (const std::pair<const std::string, int>& req : required)
|
||
{
|
||
const std::map<ItemType, int>::const_iterator it =
|
||
b.inputBuffer.counts.find(ItemType{req.first});
|
||
const int have = (it != b.inputBuffer.counts.end()) ? it->second : 0;
|
||
if (have < req.second)
|
||
{
|
||
return false;
|
||
}
|
||
}
|
||
return true;
|
||
}
|
||
|
||
// Recipe buildings: startable if any candidate recipe's inputs are satisfied.
|
||
// A Miner recipe has no inputs, so an idle Miner is always startable and its
|
||
// only idle reason is a full output buffer.
|
||
for (const RecipeDef* recipe : gatherCandidateRecipes(b))
|
||
{
|
||
if (recipeInputsAvailable(b, *recipe))
|
||
{
|
||
return true;
|
||
}
|
||
}
|
||
return false;
|
||
}
|
||
|
||
std::optional<ProductionStatus>
|
||
BuildingSystem::getProductionStatus(const Building& building) const
|
||
{
|
||
// Salvage Bay has no recipe or production cycle (REQ-BLD-SALVAGE-BAY): it is
|
||
// "producing" while it holds scrap to push out, and starved when empty.
|
||
if (building.type == BuildingType::SalvageBay)
|
||
{
|
||
return building.getOutputItemCount() >= 1 ? ProductionStatus::Producing
|
||
: ProductionStatus::Starved;
|
||
}
|
||
|
||
// Only the five recipe/cycle production types show a status light besides the
|
||
// Salvage Bay; belts, splitters, tunnels, HQ, and stations show none.
|
||
if (!isProductionBuildingType(building.type))
|
||
{
|
||
return std::nullopt;
|
||
}
|
||
|
||
// Grey only applies to player-configured types; auto-recipe buildings
|
||
// (Smelter, Reprocessing Plant) always run an implicit recipe.
|
||
if (!isAutoRecipeBuildingType(building.type) && building.recipeId.empty())
|
||
{
|
||
return ProductionStatus::Unconfigured;
|
||
}
|
||
|
||
if (building.production.has_value())
|
||
{
|
||
return ProductionStatus::Producing;
|
||
}
|
||
|
||
// Idle: missing inputs (red) take precedence over a full output buffer
|
||
// (yellow). If inputs are present yet the building is idle, the only remaining
|
||
// reason it could not start a cycle is a full output buffer (REQ-MAT-CYCLE).
|
||
return hasInputsToStart(building) ? ProductionStatus::Blocked
|
||
: ProductionStatus::Starved;
|
||
}
|
||
|
||
std::vector<BuildingSystem::BeltTileInfo> BuildingSystem::getAllBeltTiles() const
|
||
{
|
||
std::vector<BeltTileInfo> result;
|
||
for (const Building& b : m_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;
|
||
}
|
||
|
||
bool BuildingSystem::isTileOccupied(QPoint tile) const
|
||
{
|
||
return m_tileOccupancy.count({tile.x(), tile.y()}) > 0;
|
||
}
|
||
|
||
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 auto firstIt = m_tileOccupancy.find({firstAbs.x(), firstAbs.y()});
|
||
if (firstIt == m_tileOccupancy.end()) { return std::nullopt; }
|
||
const BuildingId candidateId = firstIt->second;
|
||
|
||
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)
|
||
{
|
||
return std::nullopt;
|
||
}
|
||
}
|
||
|
||
// Verify the candidate is the same building type with the same cell count.
|
||
for (const ConstructionSite& site : m_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_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.
|
||
for (ConstructionSite& site : m_constructionQueue)
|
||
{
|
||
if (site.id == id)
|
||
{
|
||
site.rotation = newRotation;
|
||
return;
|
||
}
|
||
}
|
||
|
||
// Operational building path.
|
||
for (Building& b : m_buildings)
|
||
{
|
||
if (b.id != id) { continue; }
|
||
|
||
b.rotation = newRotation;
|
||
|
||
const BuildingDef* def = m_config.buildings.findBuildingDef(b.type);
|
||
if (!def) { return; }
|
||
const ParsedSurfaceMask mask = parseSurfaceMask(def->surfaceMask, newRotation);
|
||
|
||
b.outputPorts.clear();
|
||
for (const Port& port : mask.outputPorts)
|
||
{
|
||
Port absPort;
|
||
absPort.tile = b.anchor + port.tile;
|
||
absPort.direction = port.direction;
|
||
b.outputPorts.push_back(absPort);
|
||
}
|
||
// The output ports moved; discard any in-flight emerging items and re-size
|
||
// the lanes to the new port set (REQ-MAT-OUTPUT-EMERGE).
|
||
b.emergingItems.clear();
|
||
b.emergingItems.resize(b.outputPorts.size());
|
||
b.inputPorts = computeInputPorts(b);
|
||
// 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(), {});
|
||
|
||
// Re-register with BeltSystem (items on tile are discarded). A splitter's
|
||
// filters live in BeltSystem and would be lost by removeTile, so capture
|
||
// them first and hand them back to reregisterBeltTile (REQ-BLD-SPLITTER).
|
||
if (isBeltSubsystemType(b.type))
|
||
{
|
||
std::vector<ItemType> splitterFilterA;
|
||
std::vector<ItemType> splitterFilterB;
|
||
if (b.type == BuildingType::Splitter)
|
||
{
|
||
if (const std::optional<BeltSystem::SplitterInfo> info =
|
||
m_belts.getSplitterInfo(b.anchor))
|
||
{
|
||
splitterFilterA = info->filterA;
|
||
splitterFilterB = info->filterB;
|
||
}
|
||
}
|
||
|
||
m_belts.removeTile(b.anchor);
|
||
reregisterBeltTile(b, splitterFilterA, splitterFilterB);
|
||
}
|
||
|
||
return;
|
||
}
|
||
}
|
||
|
||
const Building* BuildingSystem::findNearestBuilding(QVector2D worldPos,
|
||
BuildingType type) const
|
||
{
|
||
const Building* best = nullptr;
|
||
float bestDist = std::numeric_limits<float>::max();
|
||
for (const Building& b : m_buildings)
|
||
{
|
||
if (b.type != type)
|
||
{
|
||
continue;
|
||
}
|
||
QVector2D center(b.anchor.x() + b.footprint.width() / 2.0f,
|
||
b.anchor.y() + b.footprint.height() / 2.0f);
|
||
float dist = (center - worldPos).length();
|
||
if (dist < bestDist)
|
||
{
|
||
bestDist = dist;
|
||
best = &b;
|
||
}
|
||
}
|
||
return best;
|
||
}
|
||
|
||
bool BuildingSystem::deliverScrapToSalvageBay(BuildingId bayId)
|
||
{
|
||
Building* bay = nullptr;
|
||
for (Building& b : m_buildings)
|
||
{
|
||
if (b.id == bayId)
|
||
{
|
||
bay = &b;
|
||
break;
|
||
}
|
||
}
|
||
if (!bay || bay->type != BuildingType::SalvageBay)
|
||
{
|
||
return false;
|
||
}
|
||
if (bay->queuedForDeconstruction)
|
||
{
|
||
return false; // queued for deconstruction: stopped operating (REQ-BLD-DECON-QUEUE)
|
||
}
|
||
// Emerging scrap still counts against the bay's holding capacity
|
||
// (REQ-MAT-OUTPUT-EMERGE).
|
||
if (bay->getOutputItemCount() >= bay->outputBuffer.capacity)
|
||
{
|
||
return false;
|
||
}
|
||
bay->outputBuffer.items.push_back(Item{ItemType{"scrap"}});
|
||
return true;
|
||
}
|
||
|
||
BuildingId BuildingSystem::placeImmediate(BuildingType type,
|
||
const std::vector<std::string>& surfaceMask,
|
||
QPoint anchor, Rotation rotation)
|
||
{
|
||
const BuildingId id = m_allocateBuildingId();
|
||
const ParsedSurfaceMask mask = parseSurfaceMask(surfaceMask, rotation);
|
||
|
||
Building building;
|
||
building.id = id;
|
||
building.anchor = anchor;
|
||
building.footprint = mask.footprint;
|
||
building.rotation = rotation;
|
||
building.type = type;
|
||
|
||
for (const QPoint& cell : mask.bodyCells)
|
||
{
|
||
const QPoint absCell = anchor + cell;
|
||
building.bodyCells.push_back(absCell);
|
||
m_tileOccupancy[{absCell.x(), absCell.y()}] = id;
|
||
}
|
||
for (const Port& port : mask.outputPorts)
|
||
{
|
||
Port absPort;
|
||
absPort.tile = anchor + port.tile;
|
||
absPort.direction = port.direction;
|
||
building.outputPorts.push_back(absPort);
|
||
}
|
||
building.emergingItems.resize(building.outputPorts.size());
|
||
building.inputPorts = computeInputPorts(building);
|
||
building.incomingItems.assign(building.inputPorts.size(), {});
|
||
|
||
if (type == BuildingType::SalvageBay)
|
||
{
|
||
initSalvageBayBuffer(building);
|
||
}
|
||
|
||
m_buildings.push_back(std::move(building));
|
||
return id;
|
||
}
|
||
|
||
bool BuildingSystem::removeBuilding(BuildingId id)
|
||
{
|
||
for (std::vector<Building>::iterator it = m_buildings.begin();
|
||
it != m_buildings.end();
|
||
++it)
|
||
{
|
||
if (it->id == id)
|
||
{
|
||
if (it->type == BuildingType::Belt || it->type == BuildingType::Splitter
|
||
|| it->type == BuildingType::TunnelEntry || it->type == BuildingType::TunnelExit)
|
||
{
|
||
m_belts.removeTile(it->anchor);
|
||
}
|
||
for (const QPoint& cell : it->bodyCells)
|
||
{
|
||
m_tileOccupancy.erase({cell.x(), cell.y()});
|
||
}
|
||
m_buildings.erase(it);
|
||
return true;
|
||
}
|
||
}
|
||
return false;
|
||
}
|
||
|
||
void BuildingSystem::forEachBuilding(std::function<void(Building&)> fn)
|
||
{
|
||
for (Building& b : m_buildings)
|
||
{
|
||
fn(b);
|
||
}
|
||
}
|
||
|
||
void BuildingSystem::registerTileOccupancy(const std::vector<QPoint>& cells,
|
||
BuildingId ownerPlaceholder)
|
||
{
|
||
for (const QPoint& cell : cells)
|
||
{
|
||
m_tileOccupancy[{cell.x(), cell.y()}] = ownerPlaceholder;
|
||
}
|
||
}
|
||
|
||
void BuildingSystem::unregisterTileOccupancy(const std::vector<QPoint>& cells)
|
||
{
|
||
for (const QPoint& cell : cells)
|
||
{
|
||
m_tileOccupancy.erase({cell.x(), cell.y()});
|
||
}
|
||
}
|
||
|
||
namespace
|
||
{
|
||
void appendItems(Hasher& hasher, const std::vector<Item>& items)
|
||
{
|
||
hasher.append(items.size());
|
||
for (const Item& item : items)
|
||
{
|
||
hasher.append(item.type.id);
|
||
}
|
||
}
|
||
|
||
void appendInputBuffer(Hasher& hasher, const InputBuffer& buffer)
|
||
{
|
||
// std::map<ItemType, int> iterates in sorted-id order (ItemType::operator<).
|
||
hasher.append(buffer.counts.size());
|
||
for (const std::pair<const ItemType, int>& entry : buffer.counts)
|
||
{
|
||
hasher.append(entry.first.id);
|
||
hasher.append(entry.second);
|
||
}
|
||
hasher.append(buffer.caps.size());
|
||
for (const std::pair<const ItemType, int>& entry : buffer.caps)
|
||
{
|
||
hasher.append(entry.first.id);
|
||
hasher.append(entry.second);
|
||
}
|
||
}
|
||
} // namespace
|
||
|
||
void BuildingSystem::appendChecksum(Hasher& hasher) const
|
||
{
|
||
// m_buildings keeps a stable, deterministic order (append on build, swap-free
|
||
// erase aside — both runs perform identical operations, so order matches).
|
||
hasher.append(m_buildings.size());
|
||
for (const Building& b : m_buildings)
|
||
{
|
||
hasher.append(b.id);
|
||
hasher.append(b.anchor);
|
||
hasher.append(b.footprint.width());
|
||
hasher.append(b.footprint.height());
|
||
hasher.append(b.rotation);
|
||
hasher.append(b.type);
|
||
hasher.append(b.recipeId);
|
||
appendInputBuffer(hasher, b.inputBuffer);
|
||
appendItems(hasher, b.outputBuffer.items);
|
||
hasher.append(b.outputBuffer.capacity);
|
||
hasher.append(b.emergingItems.size());
|
||
for (const std::vector<BeltItemSlot>& lane : b.emergingItems)
|
||
{
|
||
hasher.append(lane.size());
|
||
for (const BeltItemSlot& slot : lane)
|
||
{
|
||
hasher.append(slot.item.type.id);
|
||
hasher.append(slot.progress);
|
||
}
|
||
}
|
||
hasher.append(b.incomingItems.size());
|
||
for (const std::vector<BeltItemSlot>& lane : b.incomingItems)
|
||
{
|
||
hasher.append(lane.size());
|
||
for (const BeltItemSlot& slot : lane)
|
||
{
|
||
hasher.append(slot.item.type.id);
|
||
hasher.append(slot.progress);
|
||
}
|
||
}
|
||
hasher.append(b.production.has_value());
|
||
if (b.production.has_value())
|
||
{
|
||
hasher.append(b.production->recipeId);
|
||
hasher.append(b.production->completesAt);
|
||
appendItems(hasher, b.production->chosenOutputs);
|
||
}
|
||
hasher.append(b.shipLayout.has_value());
|
||
hasher.append(b.queuedForDeconstruction);
|
||
}
|
||
|
||
hasher.append(m_constructionQueue.size());
|
||
for (const ConstructionSite& s : m_constructionQueue)
|
||
{
|
||
hasher.append(s.id);
|
||
hasher.append(s.anchor);
|
||
hasher.append(s.footprint.width());
|
||
hasher.append(s.footprint.height());
|
||
hasher.append(s.rotation);
|
||
hasher.append(s.type);
|
||
hasher.append(s.recipeId);
|
||
hasher.append(s.completesAt);
|
||
hasher.append(s.shipLayout.has_value());
|
||
hasher.append(s.splitterFilterA.size());
|
||
for (const ItemType& type : s.splitterFilterA) { hasher.append(type.id); }
|
||
hasher.append(s.splitterFilterB.size());
|
||
for (const ItemType& type : s.splitterFilterB) { hasher.append(type.id); }
|
||
}
|
||
|
||
hasher.append(m_deconstructionQueue.size());
|
||
for (const DeconstructionEntry& e : m_deconstructionQueue)
|
||
{
|
||
hasher.append(e.id);
|
||
hasher.append(e.completesAt);
|
||
hasher.append(e.splitterFilterA.size());
|
||
for (const ItemType& type : e.splitterFilterA) { hasher.append(type.id); }
|
||
hasher.append(e.splitterFilterB.size());
|
||
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);
|
||
}
|
||
}
|