When a belt drag ends with the cursor over a non-belt building or construction site, the end tile now snaps to the input-capable adjacent tile closest to the cursor and points into the target, instead of showing an invalid ghost on the occupied tile. Adds BuildingSystem::getInputPorts(id) (reusing computeInputPorts, refactored into a bodyCells+outputPorts core) as the single source of truth for a target's input-capable adjacent tiles; works for both operational buildings (stored inputPorts) and construction sites (mask-derived). GameWorldView tracks the cursor world position and, in recomputeBeltDragPath, picks the nearest input port and overrides the end tile's rotation. Ghost preview, resolve, and apply consume the snapped path unchanged. Implements REQ-BLD-BELT-DRAG snapping-to-a-building. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Y7N59FsLA5e2kuVdqe4Uhc
1845 lines
60 KiB
C++
1845 lines
60 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;
|
||
}
|
||
|
||
// 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
|
||
// ---------------------------------------------------------------------------
|
||
|
||
const BuildingDef* BuildingSystem::findBuildingDef(BuildingType type) const
|
||
{
|
||
for (const BuildingDef& def : m_config.buildings.buildings)
|
||
{
|
||
if (def.type == type)
|
||
{
|
||
return &def;
|
||
}
|
||
}
|
||
return nullptr;
|
||
}
|
||
|
||
const RecipeDef* BuildingSystem::findRecipe(const std::string& id,
|
||
BuildingType type) const
|
||
{
|
||
for (const RecipeDef& recipe : m_config.recipes.recipes)
|
||
{
|
||
if (recipe.id == id && recipe.building == type)
|
||
{
|
||
return &recipe;
|
||
}
|
||
}
|
||
return nullptr;
|
||
}
|
||
|
||
const ShipDef* BuildingSystem::findShipDef(const std::string& id) const
|
||
{
|
||
for (const ShipDef& def : m_config.ships.ships)
|
||
{
|
||
if (def.id == id)
|
||
{
|
||
return &def;
|
||
}
|
||
}
|
||
return nullptr;
|
||
}
|
||
|
||
const ModuleDef* BuildingSystem::findModuleDef(const std::string& id) const
|
||
{
|
||
for (const ModuleDef& def : m_config.modules.modules)
|
||
{
|
||
if (def.id == id)
|
||
{
|
||
return &def;
|
||
}
|
||
}
|
||
return nullptr;
|
||
}
|
||
|
||
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 = 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 = 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 = 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 = 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 = 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 = 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;
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Demolish
|
||
// ---------------------------------------------------------------------------
|
||
|
||
int BuildingSystem::demolish(BuildingId id)
|
||
{
|
||
// Construction queue?
|
||
for (std::deque<ConstructionSite>::iterator it = m_constructionQueue.begin();
|
||
it != m_constructionQueue.end();
|
||
++it)
|
||
{
|
||
if (it->id == id)
|
||
{
|
||
const BuildingDef* def = 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?
|
||
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);
|
||
}
|
||
const BuildingDef* def = findBuildingDef(it->type);
|
||
for (const QPoint& cell : it->bodyCells)
|
||
{
|
||
m_tileOccupancy.erase({cell.x(), cell.y()});
|
||
}
|
||
m_buildings.erase(it);
|
||
if (def)
|
||
{
|
||
return def->cost * m_config.world.refundPercentage / 100;
|
||
}
|
||
return 0;
|
||
}
|
||
}
|
||
|
||
return 0;
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// 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 = findRecipe(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 = 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 = 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 = 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 = findRecipe(building.recipeId, building.type);
|
||
if (recipe)
|
||
{
|
||
initBuffers(building, *recipe);
|
||
}
|
||
}
|
||
}
|
||
|
||
// Register with BeltSystem before the move (mask stays valid).
|
||
if (front.type == BuildingType::Belt)
|
||
{
|
||
m_belts.placeBelt(front.anchor, front.rotation);
|
||
}
|
||
else if (front.type == BuildingType::Splitter)
|
||
{
|
||
assert(mask.outputPorts.size() >= 2);
|
||
m_belts.placeSplitter(front.anchor,
|
||
mask.outputPorts[0].direction,
|
||
mask.outputPorts[1].direction);
|
||
// Carry over any filters configured while under construction
|
||
// (REQ-BLD-SITE-CONFIG).
|
||
m_belts.setSplitterFilters(front.anchor,
|
||
front.splitterFilterA,
|
||
front.splitterFilterB);
|
||
}
|
||
else if (front.type == BuildingType::TunnelEntry)
|
||
{
|
||
m_belts.placeTunnelEntry(front.anchor, front.rotation, m_config.world.tunnelMaxDistance_tiles);
|
||
}
|
||
else if (front.type == BuildingType::TunnelExit)
|
||
{
|
||
m_belts.placeTunnelExit(front.anchor, front.rotation);
|
||
}
|
||
|
||
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 = findBuildingDef(m_constructionQueue.front().type);
|
||
if (nextDef)
|
||
{
|
||
m_constructionQueue.front().completesAt =
|
||
currentTick + secondsToTicks(nextDef->constructionTimeSeconds);
|
||
}
|
||
}
|
||
}
|
||
|
||
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)
|
||
{
|
||
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
|
||
}
|
||
|
||
// 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)
|
||
{
|
||
// 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)
|
||
{
|
||
if (building.type != BuildingType::Shipyard)
|
||
{
|
||
continue;
|
||
}
|
||
if (building.recipeId.empty())
|
||
{
|
||
continue;
|
||
}
|
||
const ShipDef* shipDef = 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 = 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)
|
||
{
|
||
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 = findRecipe(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 = 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 = 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
|
||
{
|
||
const BuildingDef* def = 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 = 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).
|
||
if (b.type == BuildingType::Belt)
|
||
{
|
||
m_belts.removeTile(b.anchor);
|
||
m_belts.placeBelt(b.anchor, newRotation);
|
||
}
|
||
else if (b.type == BuildingType::Splitter)
|
||
{
|
||
m_belts.removeTile(b.anchor);
|
||
assert(mask.outputPorts.size() >= 2);
|
||
m_belts.placeSplitter(b.anchor,
|
||
mask.outputPorts[0].direction,
|
||
mask.outputPorts[1].direction);
|
||
}
|
||
else if (b.type == BuildingType::TunnelEntry)
|
||
{
|
||
m_belts.removeTile(b.anchor);
|
||
m_belts.placeTunnelEntry(b.anchor, newRotation, m_config.world.tunnelMaxDistance_tiles);
|
||
}
|
||
else if (b.type == BuildingType::TunnelExit)
|
||
{
|
||
m_belts.removeTile(b.anchor);
|
||
m_belts.placeTunnelExit(b.anchor, newRotation);
|
||
}
|
||
|
||
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;
|
||
}
|
||
// 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(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); }
|
||
}
|
||
|
||
// 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);
|
||
}
|
||
}
|