#include "BuildingSystem.h" #include #include #include #include #include #include "FactoryQueries.h" #include "PlacementRules.h" #include "ProductionRules.h" #include "PortGeometry.h" #include "StateChecksum.h" #include "SurfaceMask.h" #include "tracing.h" namespace { // An input belt accepts a new item at progress 0.0 only when it holds fewer than // three items and the entry slot is clear (nothing within a quarter tile of 0.0), // matching the belt packing used elsewhere (REQ-GW-BELT-CAPACITY). bool inputLaneEntryFree(const std::vector& 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) { } // --------------------------------------------------------------------------- // Private helpers // --------------------------------------------------------------------------- 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(FactoryState& state, BuildingType type, QPoint anchor, Rotation rotation, Tick currentTick) { const BuildingDef* def = m_config.buildings.findBuildingDef(type); assert(def != nullptr); const ParsedSurfaceMask mask = parseSurfaceMask(def->surfaceMask, rotation); // Reject placements that fall outside the world (REQ-BLD-PLACE-VALID). if (!bodyCellsWithinWorldBounds(state, m_config, 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; state.grid.occupy(absCell, id); } // Build construction site. ConstructionSite site; site.id = id; site.anchor = anchor; site.footprint = mask.footprint; site.rotation = rotation; site.type = type; for (const QPoint& cell : mask.bodyCells) { site.bodyCells.push_back(anchor + cell); } if (state.constructionQueue.empty()) { site.completesAt = currentTick + secondsToTicks(def->constructionTimeSeconds); } // else: completesAt remains 0 (queued, not yet started). state.constructionQueue.push_back(std::move(site)); return id; } // --------------------------------------------------------------------------- // Deconstruct // --------------------------------------------------------------------------- int BuildingSystem::deconstruct(FactoryState& state, BuildingId id, Tick currentTick) { // Construction site? Removed instantly with the full refund; never queued // for deconstruction (REQ-BLD-DECONSTRUCT). for (std::deque::iterator it = state.constructionQueue.begin(); it != state.constructionQueue.end(); ++it) { if (it->id == id) { const BuildingDef* def = m_config.buildings.findBuildingDef(it->type); state.grid.release(it->bodyCells); state.constructionQueue.erase(it); if (def) { return def->cost; } return 0; } } // Operational building? Append it to the deconstruction queue rather than // removing it now; the partial refund is credited on completion in // tickDeconstruction (REQ-BLD-DECON-QUEUE). for (Building& building : state.buildings) { if (building.id != id) { continue; } if (building.queuedForDeconstruction) { return 0; } // already queued building.queuedForDeconstruction = true; DeconstructionEntry entry; entry.id = id; // A queued belt/tunnel/splitter stops transporting at once: capture a // splitter's filters (so an un-queue can restore them), then unregister // its tile, discarding items on it and re-pairing tunnels as if it were // gone (REQ-BLD-TUNNEL-PAIR). if (building.type == BuildingType::Splitter) { if (const std::optional info = m_belts.getSplitterInfo(building.anchor)) { entry.splitterFilterA = info->filterA; entry.splitterFilterB = info->filterB; } } if (isBeltSubsystemType(building.type)) { m_belts.removeTile(building.anchor); } const bool wasEmpty = state.deconstructionQueue.empty(); state.deconstructionQueue.push_back(std::move(entry)); if (wasEmpty) { startFrontDeconstruction(state, currentTick); } return 0; } return 0; } void BuildingSystem::startFrontDeconstruction(FactoryState& state, Tick currentTick) { if (state.deconstructionQueue.empty()) { return; } DeconstructionEntry& front = state.deconstructionQueue.front(); if (front.completesAt == 0) { front.completesAt = currentTick + secondsToTicks(m_config.world.deconstructionTimeSeconds); } } // --------------------------------------------------------------------------- // Set recipe // --------------------------------------------------------------------------- void BuildingSystem::setRecipe(FactoryState& state, BuildingId id, const std::string& recipeId) { // Construction site: store recipe for when building completes. for (ConstructionSite& site : state.constructionQueue) { if (site.id == id) { // Auto-recipe buildings have no player-selected recipe // (REQ-BLD-SMELTER, REQ-BLD-REPROCESSING); ignore any attempt to set one. if (isAutoRecipeBuildingType(site.type)) { return; } // No-op if the recipe is unchanged, so a redundant selection does // not wipe an already-configured ship layout. if (site.recipeId == recipeId) { return; } site.recipeId = recipeId; site.shipLayout = std::nullopt; return; } } // Operational building: clear buffers and re-init. for (Building& building : state.buildings) { if (building.id == id) { // Auto-recipe buildings have no player-selected recipe // (REQ-BLD-SMELTER, REQ-BLD-REPROCESSING); ignore any attempt to set one. if (isAutoRecipeBuildingType(building.type)) { return; } // No-op if the recipe is unchanged, so a redundant selection does // not wipe an already-configured ship layout or reset buffers. if (building.recipeId == recipeId) { return; } building.recipeId = recipeId; building.shipLayout = std::nullopt; building.inputBuffer.counts.clear(); building.inputBuffer.caps.clear(); building.outputBuffer.items.clear(); building.outputBuffer.capacity = 0; // Emerging items are part of the output buffer, so clearing it on a // recipe change discards them too (REQ-MAT-OUTPUT-EMERGE); in-transit // input items are discarded and their reservations released // (REQ-MAT-INPUT-INTAKE). for (std::vector& 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(m_config, building); } else { const RecipeDef* recipe = m_config.recipes.findRecipeDef(recipeId, building.type); if (recipe) { initBuffers(building, *recipe); } } } return; } } } void BuildingSystem::setShipLayout(FactoryState& state, BuildingId id, const ShipLayoutConfig& layout) { for (ConstructionSite& site : state.constructionQueue) { if (site.id == id) { site.shipLayout = layout; return; } } for (Building& building : state.buildings) { if (building.id == id) { if (building.production.has_value()) { building.production = std::nullopt; } building.shipLayout = layout; building.inputBuffer.counts.clear(); building.inputBuffer.caps.clear(); building.outputBuffer.items.clear(); building.outputBuffer.capacity = 0; for (std::vector& lane : building.emergingItems) { lane.clear(); } for (std::vector& lane : building.incomingItems) { lane.clear(); } if (!building.recipeId.empty() && building.type == BuildingType::Shipyard) { initShipyardBuffers(m_config, building); } return; } } } void BuildingSystem::setSiteSplitterFilters(FactoryState& state, BuildingId id, const std::vector& filterA, const std::vector& filterB) { for (ConstructionSite& site : state.constructionQueue) { if (site.id == id && site.type == BuildingType::Splitter) { site.splitterFilterA = filterA; site.splitterFilterB = filterB; return; } } } // --------------------------------------------------------------------------- // Tick hooks // --------------------------------------------------------------------------- void BuildingSystem::tickDeconstruction(FactoryState& state, Tick currentTick) { TRACE(); if (state.deconstructionQueue.empty()) { return; } DeconstructionEntry& front = state.deconstructionQueue.front(); // Guard: if the front entry's timer was never started, start it now. if (front.completesAt == 0) { startFrontDeconstruction(state, currentTick); return; } if (currentTick < front.completesAt) { return; } // Remove the building from the world and credit its refund (REQ-BLD-DECONSTRUCT). // Belt/tunnel/splitter tiles were already unregistered when the building was // queued (see deconstruct), so only tile occupancy and the record remain. for (std::vector::iterator it = state.buildings.begin(); it != state.buildings.end(); ++it) { if (it->id != front.id) { continue; } const BuildingDef* def = m_config.buildings.findBuildingDef(it->type); state.grid.release(it->bodyCells); state.buildings.erase(it); if (def) { m_addBuildingBlocks(def->cost * m_config.world.refundPercentage / 100); } break; } state.deconstructionQueue.pop_front(); // Start the next queued deconstruction, if any. startFrontDeconstruction(state, currentTick); } void BuildingSystem::cancelDeconstruction(FactoryState& state, BuildingId id) { for (std::deque::iterator it = state.deconstructionQueue.begin(); it != state.deconstructionQueue.end(); ++it) { if (it->id != id) { continue; } // Resume operation: clear the flag and re-register belt/tunnel/splitter // tiles that were unregistered at enqueue (which re-pairs tunnels, // REQ-BLD-TUNNEL-PAIR). Deconstruction progress is discarded; no refund. if (Building* building = findBuilding(state, id)) { building->queuedForDeconstruction = false; reregisterBeltTile(m_belts, m_config, *building, it->splitterFilterA, it->splitterFilterB); } state.deconstructionQueue.erase(it); // If the running front was removed, the new front (completesAt == 0) has // its timer started by the next tickDeconstruction guard. return; } } void BuildingSystem::tickBeltPull(FactoryState& state) { 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 : state.buildings) { // A building queued for deconstruction stops operating (REQ-BLD-DECON-QUEUE). if (building.queuedForDeconstruction) { continue; } const bool isHq = (building.type == BuildingType::Hq); // 1. Advance every input belt and deliver arrivals (progress >= 0.5) into // the input buffer — or the global stock for the HQ. Runs for all // buildings so in-transit items keep moving even when feeding is gated // off, and arrivals become consumable before tickProduction (step 4). for (std::size_t i = 0; i < building.incomingItems.size(); ++i) { std::vector& 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(FactoryState& state, BuildingId producerId, const Port& outputPort, const Item& item) { const std::optional ownerId = state.grid.findOwner(outputPort.tile); if (!ownerId.has_value() || *ownerId == producerId) { return false; } Building* consumer = findBuilding(state, *ownerId); if (!consumer) { return false; // an unbuilt construction site, or not an operational building } if (consumer->queuedForDeconstruction) { return false; // queued for deconstruction: stopped operating (REQ-BLD-DECON-QUEUE) } // The coupling is the consumer input port meeting this output port: same flow // direction, feeding the producer's output-port tile (REQ-MAT-DIRECT-COUPLE). for (std::size_t j = 0; j < consumer->inputPorts.size(); ++j) { const Port& in = consumer->inputPorts[j]; if (in.direction != outputPort.direction) { continue; } if (inputBodyTile(in.tile, in.direction) != outputPort.tile) { continue; } if (!canAcceptInput(*consumer, j, item.type)) { return false; } depositToInputBelt(*consumer, j, item); return true; } return false; } void BuildingSystem::tickProduction(FactoryState& state, Tick currentTick) { TRACE(); for (Building& building : state.buildings) { // A building queued for deconstruction stops operating (REQ-BLD-DECON-QUEUE). if (building.queuedForDeconstruction) { continue; } // Skip types without a recipe-based production loop. if (building.type == BuildingType::Belt || building.type == BuildingType::Splitter || building.type == BuildingType::Shipyard || building.type == BuildingType::SalvageBay || building.type == BuildingType::Hq) { continue; } const bool autoRecipe = isAutoRecipeBuildingType(building.type); if (!autoRecipe && building.recipeId.empty()) { continue; } // If a production cycle is active, check for completion. Completion only // needs the already-decided outputs, so it does not depend on which // recipe is selected or auto-chosen. if (building.production) { if (currentTick >= building.production->completesAt) { for (const Item& item : building.production->chosenOutputs) { building.outputBuffer.items.push_back(item); } building.production = std::nullopt; } // Whether we just completed or are still running, do not start // another cycle in the same tick. continue; } // Idle: gather the candidate recipes to try. Auto-recipe buildings // (Smelter, Reprocessing Plant) have no selected recipe and try every // recipe of their type in config order, running the first whose inputs // are satisfied (REQ-BLD-SMELTER, REQ-BLD-REPROCESSING). Other buildings // try only their selected recipe. const std::vector candidates = gatherCandidateRecipes(m_config, 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(FactoryState& state, Tick currentTick) { TRACE(); for (Building& building : state.buildings) { // A building queued for deconstruction stops operating (REQ-BLD-DECON-QUEUE). if (building.queuedForDeconstruction) { continue; } if (building.type != BuildingType::Shipyard) { continue; } if (building.recipeId.empty()) { continue; } const ShipDef* shipDef = m_config.ships.findShipDef(building.recipeId); if (!shipDef) { continue; } // If a cycle is in progress, check for completion. if (building.production) { if (currentTick >= building.production->completesAt) { if (!building.outputPorts.empty()) { const Port& p = building.outputPorts[0]; const QVector2D spawnPos(p.tile.x() + 0.5f, p.tile.y() + 0.5f); // A shipyard builds exactly what the player configured and // paid for. When no layout is set it produces a bare hull, so // pass an explicit empty layout rather than nullopt: the latter // would make ShipSystem fall back to the schematic's // defaultModules (a wave-only loadout) and yield free weapons. const std::optional 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(m_config, 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 = m_config.modules.findModuleDef(pm.moduleId); if (modDef) { totalTime += modDef->productionTimeSeconds; } } } Production prod; prod.recipeId = building.recipeId; prod.completesAt = currentTick + secondsToTicks(totalTime); building.production = std::move(prod); } } void BuildingSystem::tickOutputBelts(FactoryState& state) { 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 : state.buildings) { // A building queued for deconstruction stops operating (REQ-BLD-DECON-QUEUE). if (building.queuedForDeconstruction) { continue; } for (std::size_t p = 0; p < building.outputPorts.size(); ++p) { const Port& port = building.outputPorts[p]; std::vector& 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(state, 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 FactoryState& state, const std::function& visit) const { for (const Building& building : state.buildings) { for (std::size_t p = 0; p < building.outputPorts.size(); ++p) { const Port& port = building.outputPorts[p]; const QPoint bodyTile = outputBodyTile(port.tile, port.direction); const std::vector& 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 FactoryState& state, const std::function& visit) const { for (const Building& building : state.buildings) { for (std::size_t p = 0; p < building.inputPorts.size(); ++p) { const Port& port = building.inputPorts[p]; const QPoint bodyTile = inputBodyTile(port.tile, port.direction); const std::vector& 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 // --------------------------------------------------------------------------- void BuildingSystem::rotateInPlace(FactoryState& state, BuildingId id, Rotation newRotation) { // Construction site path — just update rotation; no ports to recompute. for (ConstructionSite& site : state.constructionQueue) { if (site.id == id) { site.rotation = newRotation; return; } } // Operational building path. for (Building& b : state.buildings) { if (b.id != id) { continue; } b.rotation = newRotation; const BuildingDef* def = m_config.buildings.findBuildingDef(b.type); if (!def) { return; } const ParsedSurfaceMask mask = parseSurfaceMask(def->surfaceMask, newRotation); b.outputPorts.clear(); for (const Port& port : mask.outputPorts) { Port absPort; absPort.tile = b.anchor + port.tile; absPort.direction = port.direction; b.outputPorts.push_back(absPort); } // The output ports moved; discard any in-flight emerging items and re-size // the lanes to the new port set (REQ-MAT-OUTPUT-EMERGE). b.emergingItems.clear(); b.emergingItems.resize(b.outputPorts.size()); b.inputPorts = computeInputPorts(b.bodyCells, b.outputPorts); // Likewise discard in-transit input items and re-size the input belts to // the new port set (REQ-MAT-INPUT-INTAKE). b.incomingItems.assign(b.inputPorts.size(), {}); // Re-register with BeltSystem (items on tile are discarded). A splitter's // filters live in BeltSystem and would be lost by removeTile, so capture // them first and hand them back to reregisterBeltTile (REQ-BLD-SPLITTER). if (isBeltSubsystemType(b.type)) { std::vector splitterFilterA; std::vector splitterFilterB; if (b.type == BuildingType::Splitter) { if (const std::optional info = m_belts.getSplitterInfo(b.anchor)) { splitterFilterA = info->filterA; splitterFilterB = info->filterB; } } m_belts.removeTile(b.anchor); reregisterBeltTile(m_belts, m_config, b, splitterFilterA, splitterFilterB); } return; } } BuildingId BuildingSystem::placeImmediate(FactoryState& state, 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); state.grid.occupy(absCell, id); } for (const Port& port : mask.outputPorts) { Port absPort; absPort.tile = anchor + port.tile; absPort.direction = port.direction; building.outputPorts.push_back(absPort); } building.emergingItems.resize(building.outputPorts.size()); building.inputPorts = computeInputPorts(building.bodyCells, building.outputPorts); building.incomingItems.assign(building.inputPorts.size(), {}); if (type == BuildingType::SalvageBay) { initSalvageBayBuffer(m_config, building); } state.buildings.push_back(std::move(building)); return id; } bool BuildingSystem::removeBuilding(FactoryState& state, BuildingId id) { for (std::vector::iterator it = state.buildings.begin(); it != state.buildings.end(); ++it) { if (it->id == id) { if (it->type == BuildingType::Belt || it->type == BuildingType::Splitter || it->type == BuildingType::TunnelEntry || it->type == BuildingType::TunnelExit) { m_belts.removeTile(it->anchor); } state.grid.release(it->bodyCells); state.buildings.erase(it); return true; } } return false; } void BuildingSystem::forEachBuilding(FactoryState& state, std::function fn) { for (Building& b : state.buildings) { fn(b); } } void BuildingSystem::registerTileOccupancy(FactoryState& state, const std::vector& cells, BuildingId ownerPlaceholder) { state.grid.occupy(cells, ownerPlaceholder); } void BuildingSystem::unregisterTileOccupancy(FactoryState& state, const std::vector& cells) { state.grid.release(cells); } 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(const FactoryState& state, Hasher& hasher) const { // state.buildings keeps a stable, deterministic order (append on build, swap-free // erase aside — both runs perform identical operations, so order matches). hasher.append(state.buildings.size()); for (const Building& b : state.buildings) { hasher.append(b.id); hasher.append(b.anchor); hasher.append(b.footprint.width()); hasher.append(b.footprint.height()); hasher.append(b.rotation); hasher.append(b.type); hasher.append(b.recipeId); appendInputBuffer(hasher, b.inputBuffer); appendItems(hasher, b.outputBuffer.items); hasher.append(b.outputBuffer.capacity); hasher.append(b.emergingItems.size()); for (const std::vector& 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(b.queuedForDeconstruction); } hasher.append(state.constructionQueue.size()); for (const ConstructionSite& s : state.constructionQueue) { hasher.append(s.id); hasher.append(s.anchor); hasher.append(s.footprint.width()); hasher.append(s.footprint.height()); hasher.append(s.rotation); hasher.append(s.type); hasher.append(s.recipeId); hasher.append(s.completesAt); hasher.append(s.shipLayout.has_value()); hasher.append(s.splitterFilterA.size()); for (const ItemType& type : s.splitterFilterA) { hasher.append(type.id); } hasher.append(s.splitterFilterB.size()); for (const ItemType& type : s.splitterFilterB) { hasher.append(type.id); } } hasher.append(state.deconstructionQueue.size()); for (const DeconstructionEntry& e : state.deconstructionQueue) { hasher.append(e.id); hasher.append(e.completesAt); hasher.append(e.splitterFilterA.size()); for (const ItemType& type : e.splitterFilterA) { hasher.append(type.id); } hasher.append(e.splitterFilterB.size()); for (const ItemType& type : e.splitterFilterB) { hasher.append(type.id); } } state.grid.appendChecksum(hasher); }