#include "BuildingSystem.h" #include #include #include #include #include #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& lane) { return lane.size() < 3 && (lane.empty() || lane.back().progress >= 0.25); } } // namespace BuildingSystem::BuildingSystem(const GameConfig& config, BeltSystem& belts, std::function allocateBuildingId, std::function addBuildingBlocks, std::function&)> spawnShip, std::function 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 BuildingSystem::computeInputPorts(const Building& b) const { return computeInputPorts(b.bodyCells, b.outputPorts); } std::vector BuildingSystem::computeInputPorts( const std::vector& bodyCells, const std::vector& outputPorts) const { // Build lookup sets for quick membership checks. std::set> bodySet; for (const QPoint& cell : bodyCells) { bodySet.insert({cell.x(), cell.y()}); } std::set> 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> seen; std::vector 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 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 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 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 BuildingSystem::rollReprocessingOutput(const RecipeDef& recipe) { std::vector eligible; std::vector 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 dist(weights.begin(), weights.end()); const RecipeOutput& chosen = *eligible[static_cast(dist(m_rng))]; std::vector 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 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& 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::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::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& lane : building.emergingItems) { lane.clear(); } for (std::vector& 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& lane : building.emergingItems) { lane.clear(); } for (std::vector& lane : building.incomingItems) { lane.clear(); } if (!building.recipeId.empty() && building.type == BuildingType::Shipyard) { initShipyardBuffers(building); } return; } } } std::optional 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{}, 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& filterA, const std::vector& 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{}, 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& 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 peeked = m_belts.peekItem(building.inputPorts[i]); if (!peeked) { continue; } if (!canAcceptInput(building, i, *peeked)) { continue; } const std::optional 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::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, 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 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 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(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 layout = building.shipLayout.has_value() ? building.shipLayout : std::make_optional(); m_spawnShip(building.recipeId, spawnPos, layout); } building.production = std::nullopt; } continue; } // Build combined materials list (base + modules). const std::map requiredMaterials = computeShipyardRequiredMaterials(building); // Idle: check if all combined materials are available. bool inputsOk = true; for (const std::pair& req : requiredMaterials) { const ItemType type{req.first}; const std::map::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& 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& 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& 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& 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(lane.size()) - 1; i >= 0; --i) { visit(lane[i].item.type, beltSlotWorldPos(bodyTile, port.direction, lane[i].progress)); } } } } void BuildingSystem::forEachIncomingItem( const std::function& 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& 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(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 BuildingSystem::getAllBuildings() const { return m_buildings; } std::vector BuildingSystem::getAllSites() const { return std::vector(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 BuildingSystem::gatherCandidateRecipes(const Building& b) const { std::vector 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::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 BuildingSystem::computeShipyardRequiredMaterials(const Building& b) const { std::map 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 required = computeShipyardRequiredMaterials(b); for (const std::pair& req : required) { const std::map::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 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::getAllBeltTiles() const { std::vector 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 BuildingSystem::findRotateInPlaceTarget( BuildingType type, QPoint anchor, Rotation rot) const { // Tunnel Entries and Tunnel Exits cannot be rotated in place; re-orienting a // tunnel requires demolishing and re-placing it (REQ-BLD-ROTATE-IN-PLACE). if (type == BuildingType::TunnelEntry || type == BuildingType::TunnelExit) { return std::nullopt; } 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::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& 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::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 fn) { for (Building& b : m_buildings) { fn(b); } } void BuildingSystem::registerTileOccupancy(const std::vector& cells, BuildingId ownerPlaceholder) { for (const QPoint& cell : cells) { m_tileOccupancy[{cell.x(), cell.y()}] = ownerPlaceholder; } } void BuildingSystem::unregisterTileOccupancy(const std::vector& cells) { for (const QPoint& cell : cells) { m_tileOccupancy.erase({cell.x(), cell.y()}); } } namespace { void appendItems(Hasher& hasher, const std::vector& items) { hasher.append(items.size()); for (const Item& item : items) { hasher.append(item.type.id); } } void appendInputBuffer(Hasher& hasher, const InputBuffer& buffer) { // std::map iterates in sorted-id order (ItemType::operator<). hasher.append(buffer.counts.size()); for (const std::pair& entry : buffer.counts) { hasher.append(entry.first.id); hasher.append(entry.second); } hasher.append(buffer.caps.size()); for (const std::pair& 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& 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& 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, BuildingId>& entry : m_tileOccupancy) { hasher.append(entry.first.first); hasher.append(entry.first.second); hasher.append(entry.second); } }