Ten queries that read nothing but FactoryState become free functions in FactoryQueries.h; the BuildingSystem methods stay as one-line forwards, so no existing caller moves yet. That lets the AI path drop its dependency on the system entirely. AiSystem, SalvagerSystem, DeliverScrapEvaluator and DeliverScrapExecutor took a BuildingSystem& purely to call findBuilding, findNearestBuilding and deliverScrapToSalvageBay — all three are state-pure — so they now take FactoryState& and say what they actually read. Four forward declarations of BuildingSystem go with them. No facade: the queries are plain free functions over the data. A facade was considered to spare the ~180 UI call sites, but the AI needed only the data and would have been given GameConfig it has no use for. isProductionBuildingType moves to BuildingType.h beside isAutoRecipeBuildingType and isBeltSubsystemType rather than being copied into the new file. 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
1793 lines
60 KiB
C++
1793 lines
60 KiB
C++
#include "BuildingSystem.h"
|
||
|
||
#include <algorithm>
|
||
#include <cassert>
|
||
#include <limits>
|
||
#include <random>
|
||
#include <set>
|
||
|
||
#include "FactoryQueries.h"
|
||
#include "PortGeometry.h"
|
||
#include "StateChecksum.h"
|
||
#include "SurfaceMask.h"
|
||
#include "tracing.h"
|
||
|
||
namespace
|
||
{
|
||
// 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,
|
||
FactoryState& state,
|
||
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_state(state)
|
||
, 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_state.grid.occupy(absCell, 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_state.constructionQueue.empty())
|
||
{
|
||
site.completesAt = currentTick + secondsToTicks(def->constructionTimeSeconds);
|
||
}
|
||
// else: completesAt remains 0 (queued, not yet started).
|
||
|
||
m_state.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_state.constructionQueue.begin();
|
||
it != m_state.constructionQueue.end();
|
||
++it)
|
||
{
|
||
if (it->id == id)
|
||
{
|
||
const BuildingDef* def = m_config.buildings.findBuildingDef(it->type);
|
||
m_state.grid.release(it->bodyCells);
|
||
m_state.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_state.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_state.deconstructionQueue.empty();
|
||
m_state.deconstructionQueue.push_back(std::move(entry));
|
||
if (wasEmpty)
|
||
{
|
||
startFrontDeconstruction(currentTick);
|
||
}
|
||
return 0;
|
||
}
|
||
|
||
return 0;
|
||
}
|
||
|
||
void BuildingSystem::startFrontDeconstruction(Tick currentTick)
|
||
{
|
||
if (m_state.deconstructionQueue.empty()) { return; }
|
||
DeconstructionEntry& front = m_state.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_state.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_state.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_state.constructionQueue)
|
||
{
|
||
if (site.id == id)
|
||
{
|
||
site.shipLayout = layout;
|
||
return;
|
||
}
|
||
}
|
||
|
||
for (Building& building : m_state.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_state.constructionQueue)
|
||
{
|
||
if (site.id != id) { continue; }
|
||
if (site.type != BuildingType::Splitter) { return std::nullopt; }
|
||
|
||
const BuildingDef* def = m_config.buildings.findBuildingDef(site.type);
|
||
const ParsedSurfaceMask mask = parseSurfaceMask(
|
||
def ? def->surfaceMask : std::vector<std::string>{}, site.rotation);
|
||
if (mask.outputPorts.size() < 2) { return std::nullopt; }
|
||
|
||
BeltSystem::SplitterInfo info;
|
||
info.outputA = mask.outputPorts[0].direction;
|
||
info.outputB = mask.outputPorts[1].direction;
|
||
info.filterA = site.splitterFilterA;
|
||
info.filterB = site.splitterFilterB;
|
||
return info;
|
||
}
|
||
return std::nullopt;
|
||
}
|
||
|
||
void BuildingSystem::setSiteSplitterFilters(BuildingId id,
|
||
const std::vector<ItemType>& filterA,
|
||
const std::vector<ItemType>& filterB)
|
||
{
|
||
for (ConstructionSite& site : m_state.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_state.constructionQueue.empty())
|
||
{
|
||
return;
|
||
}
|
||
|
||
ConstructionSite& front = m_state.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_state.buildings.push_back(std::move(building));
|
||
|
||
m_state.constructionQueue.pop_front();
|
||
|
||
// Start next queued site if present.
|
||
if (!m_state.constructionQueue.empty() && m_state.constructionQueue.front().completesAt == 0)
|
||
{
|
||
const BuildingDef* nextDef =
|
||
m_config.buildings.findBuildingDef(m_state.constructionQueue.front().type);
|
||
if (nextDef)
|
||
{
|
||
m_state.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_state.deconstructionQueue.empty())
|
||
{
|
||
return;
|
||
}
|
||
|
||
DeconstructionEntry& front = m_state.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_state.buildings.begin();
|
||
it != m_state.buildings.end();
|
||
++it)
|
||
{
|
||
if (it->id != front.id) { continue; }
|
||
|
||
const BuildingDef* def = m_config.buildings.findBuildingDef(it->type);
|
||
m_state.grid.release(it->bodyCells);
|
||
m_state.buildings.erase(it);
|
||
if (def)
|
||
{
|
||
m_addBuildingBlocks(def->cost * m_config.world.refundPercentage / 100);
|
||
}
|
||
break;
|
||
}
|
||
|
||
m_state.deconstructionQueue.pop_front();
|
||
|
||
// Start the next queued deconstruction, if any.
|
||
startFrontDeconstruction(currentTick);
|
||
}
|
||
|
||
void BuildingSystem::cancelDeconstruction(BuildingId id)
|
||
{
|
||
for (std::deque<DeconstructionEntry>::iterator it = m_state.deconstructionQueue.begin();
|
||
it != m_state.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_state.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_state.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::optional<BuildingId> ownerId = m_state.grid.findOwner(outputPort.tile);
|
||
if (!ownerId.has_value() || *ownerId == producerId)
|
||
{
|
||
return false;
|
||
}
|
||
|
||
Building* consumer = findBuildingMutable(*ownerId);
|
||
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_state.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_state.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_state.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_state.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_state.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
|
||
{
|
||
return ::findBuilding(m_state, id);
|
||
}
|
||
|
||
Building* BuildingSystem::findBuildingMutable(BuildingId id)
|
||
{
|
||
return ::findBuilding(m_state, id);
|
||
}
|
||
|
||
const ConstructionSite* BuildingSystem::findSite(BuildingId id) const
|
||
{
|
||
return ::findSite(m_state, id);
|
||
}
|
||
|
||
std::vector<Building> BuildingSystem::getAllBuildings() const
|
||
{
|
||
return ::getAllBuildings(m_state);
|
||
}
|
||
|
||
std::vector<ConstructionSite> BuildingSystem::getAllSites() const
|
||
{
|
||
return ::getAllSites(m_state);
|
||
}
|
||
|
||
int BuildingSystem::getProductionBuildingCount() const
|
||
{
|
||
return ::getProductionBuildingCount(m_state);
|
||
}
|
||
|
||
int BuildingSystem::getActiveProductionBuildingCount() const
|
||
{
|
||
return ::getActiveProductionBuildingCount(m_state);
|
||
}
|
||
|
||
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_state.buildings)
|
||
{
|
||
if (b.type != BuildingType::Belt && b.type != BuildingType::Splitter)
|
||
{
|
||
continue;
|
||
}
|
||
BeltTileInfo info;
|
||
info.buildingId = b.id;
|
||
info.tile = b.bodyCells.empty() ? b.anchor : b.bodyCells[0];
|
||
info.type = b.type;
|
||
if (!b.outputPorts.empty())
|
||
{
|
||
info.directionA = b.outputPorts[0].direction;
|
||
info.directionB = b.outputPorts[0].direction;
|
||
}
|
||
else
|
||
{
|
||
info.directionA = b.rotation;
|
||
info.directionB = b.rotation;
|
||
}
|
||
if (b.type == BuildingType::Splitter && b.outputPorts.size() >= 2)
|
||
{
|
||
info.directionB = b.outputPorts[1].direction;
|
||
}
|
||
result.push_back(info);
|
||
}
|
||
return result;
|
||
}
|
||
|
||
bool BuildingSystem::isTileOccupied(QPoint tile) const
|
||
{
|
||
return ::isTileOccupied(m_state, tile);
|
||
}
|
||
|
||
std::optional<BuildingId> BuildingSystem::findRotateInPlaceTarget(
|
||
BuildingType type, QPoint anchor, Rotation rot) const
|
||
{
|
||
// Tunnel Entries and Tunnel Exits cannot be rotated in place; re-orienting a
|
||
// tunnel requires deconstructing and re-placing it (REQ-BLD-ROTATE-IN-PLACE).
|
||
if (type == BuildingType::TunnelEntry || type == BuildingType::TunnelExit)
|
||
{
|
||
return std::nullopt;
|
||
}
|
||
|
||
const BuildingDef* def = m_config.buildings.findBuildingDef(type);
|
||
if (!def) { return std::nullopt; }
|
||
|
||
const ParsedSurfaceMask mask = parseSurfaceMask(def->surfaceMask, rot);
|
||
if (mask.bodyCells.empty()) { return std::nullopt; }
|
||
|
||
// All body cells must be occupied by the same entity.
|
||
const QPoint firstAbs = anchor + mask.bodyCells[0];
|
||
const std::optional<BuildingId> firstOwner = m_state.grid.findOwner(firstAbs);
|
||
if (!firstOwner.has_value()) { return std::nullopt; }
|
||
const BuildingId candidateId = *firstOwner;
|
||
|
||
for (const QPoint& rel : mask.bodyCells)
|
||
{
|
||
const std::optional<BuildingId> owner = m_state.grid.findOwner(anchor + rel);
|
||
if (!owner.has_value() || *owner != candidateId)
|
||
{
|
||
return std::nullopt;
|
||
}
|
||
}
|
||
|
||
// Verify the candidate is the same building type with the same cell count.
|
||
for (const ConstructionSite& site : m_state.constructionQueue)
|
||
{
|
||
if (site.id != candidateId) { continue; }
|
||
if (site.type != type) { return std::nullopt; }
|
||
if (site.bodyCells.size() != mask.bodyCells.size()) { return std::nullopt; }
|
||
return candidateId;
|
||
}
|
||
for (const Building& b : m_state.buildings)
|
||
{
|
||
if (b.id != candidateId) { continue; }
|
||
if (b.type != type) { return std::nullopt; }
|
||
if (b.bodyCells.size() != mask.bodyCells.size()) { return std::nullopt; }
|
||
return candidateId;
|
||
}
|
||
|
||
return std::nullopt;
|
||
}
|
||
|
||
void BuildingSystem::rotateInPlace(BuildingId id, Rotation newRotation)
|
||
{
|
||
// Construction site path — just update rotation; no ports to recompute.
|
||
for (ConstructionSite& site : m_state.constructionQueue)
|
||
{
|
||
if (site.id == id)
|
||
{
|
||
site.rotation = newRotation;
|
||
return;
|
||
}
|
||
}
|
||
|
||
// Operational building path.
|
||
for (Building& b : m_state.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
|
||
{
|
||
return ::findNearestBuilding(m_state, worldPos, type);
|
||
}
|
||
|
||
bool BuildingSystem::deliverScrapToSalvageBay(BuildingId bayId)
|
||
{
|
||
return ::deliverScrapToSalvageBay(m_state, bayId);
|
||
}
|
||
|
||
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_state.grid.occupy(absCell, 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_state.buildings.push_back(std::move(building));
|
||
return id;
|
||
}
|
||
|
||
bool BuildingSystem::removeBuilding(BuildingId id)
|
||
{
|
||
for (std::vector<Building>::iterator it = m_state.buildings.begin();
|
||
it != m_state.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);
|
||
}
|
||
m_state.grid.release(it->bodyCells);
|
||
m_state.buildings.erase(it);
|
||
return true;
|
||
}
|
||
}
|
||
return false;
|
||
}
|
||
|
||
void BuildingSystem::forEachBuilding(std::function<void(Building&)> fn)
|
||
{
|
||
for (Building& b : m_state.buildings)
|
||
{
|
||
fn(b);
|
||
}
|
||
}
|
||
|
||
void BuildingSystem::registerTileOccupancy(const std::vector<QPoint>& cells,
|
||
BuildingId ownerPlaceholder)
|
||
{
|
||
m_state.grid.occupy(cells, ownerPlaceholder);
|
||
}
|
||
|
||
void BuildingSystem::unregisterTileOccupancy(const std::vector<QPoint>& cells)
|
||
{
|
||
m_state.grid.release(cells);
|
||
}
|
||
|
||
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_state.buildings keeps a stable, deterministic order (append on build, swap-free
|
||
// erase aside — both runs perform identical operations, so order matches).
|
||
hasher.append(m_state.buildings.size());
|
||
for (const Building& b : m_state.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_state.constructionQueue.size());
|
||
for (const ConstructionSite& s : m_state.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_state.deconstructionQueue.size());
|
||
for (const DeconstructionEntry& e : m_state.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); }
|
||
}
|
||
|
||
m_state.grid.appendChecksum(hasher);
|
||
}
|