#include "ProductionSystem.h" #include #include #include #include #include "BeltSystem.h" #include "BuildingBuffers.h" #include "FactoryQueries.h" #include "PortGeometry.h" #include "ProductionRules.h" #include "tracing.h" ProductionSystem::ProductionSystem(const GameConfig& config, std::function&)> spawnShip, std::function isItemUnlocked, std::mt19937& rng) : m_config(config) , m_spawnShip(std::move(spawnShip)) , m_isItemUnlocked(std::move(isItemUnlocked)) , m_rng(rng) { } 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); } // The items of one group, produced together (REQ-MAT-OUTPUT-GROUP). std::vector itemsOf(const RecipeOutputGroup& group) { std::vector result; for (const RecipeOutput& out : group.items) { Item item; item.type.id = out.item; for (int i = 0; i < out.amount; ++i) { result.push_back(item); } } return result; } } // namespace std::vector ProductionSystem::rollOutputGroup(const RecipeDef& recipe) { // One group: nothing to choose, so no weight is read, no draw is made, and no // eligibility is tested (REQ-MAT-OUTPUT-GROUP, REQ-LOCK-OUTPUT-POOL). // // Not drawing matters beyond speed. A draw here would consume entropy for every // ordinary recipe, shifting every later random outcome and invalidating recorded // replays. And eligibility must not apply either: implicit unlocking is derived from // demand, so an ordinary recipe's output can be perfectly producible while nothing // yet calls for it -- testing it here would stop the building producing at all. if (recipe.outputGroups.size() == 1) { return itemsOf(recipe.outputGroups.front()); } // Several groups: only those whose items are all unlocked can be picked, and a group // holding any locked item is dropped whole, since its items come together // (REQ-LOCK-OUTPUT-POOL). Weights are renormalized over what is left by // discrete_distribution. std::vector eligible; std::vector weights; for (const RecipeOutputGroup& group : recipe.outputGroups) { bool allUnlocked = true; for (const RecipeOutput& out : group.items) { if (!m_isItemUnlocked(out.item)) { allUnlocked = false; break; } } if (!allUnlocked) { continue; } eligible.push_back(&group); weights.push_back(group.probability.value_or(1.0)); } if (eligible.empty()) { return {}; } std::discrete_distribution dist(weights.begin(), weights.end()); return itemsOf(*eligible[static_cast(dist(m_rng))]); } void ProductionSystem::tickBeltPull(FactoryState& state, BeltSystem& belts) { 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 = 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) { state.buildingBlocksStock += 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 = belts.peekItem(building.inputPorts[i]); if (!peeked) { continue; } // A Smelter or Reprocessing Plant without a recipe takes the first material // offered to it as its selection (REQ-BLD-AUTO-RECIPE); the ports are walked // in order, so which offer comes first is fixed. selectAutoRecipeIfUnset(building, *peeked); if (!canAcceptInput(building, i, *peeked)) { continue; } const std::optional taken = belts.tryTakeItem(building.inputPorts[i]); if (taken) { depositToInputBelt(building, i, *taken); } } } } void ProductionSystem::selectAutoRecipeIfUnset(Building& building, const ItemType& offered) { // Only while it holds none: once set, a recipe is the player's to change // (REQ-BLD-AUTO-RECIPE). Buildings that select their own recipe are the only ones // this applies to; everyone else ignores an offer they have no recipe for. if (!building.recipeId.empty()) { return; } const RecipeDef* recipe = findAutoRecipeFor(m_config, building.type, offered); if (!recipe) { return; } building.recipeId = recipe->id; initBuffers(building, *recipe); } bool ProductionSystem::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 ProductionSystem::depositToInputBelt(Building& consumer, std::size_t inputPortIndex, const Item& item) { consumer.incomingItems[inputPortIndex].push_back(BeltItemSlot{item, 0.0}); } bool ProductionSystem::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; } // A coupling is an offer too, so an unset auto-recipe building selects from it // (REQ-BLD-AUTO-RECIPE). Without this a Smelter placed flush against a producer // would accept nothing and leave it stuck at its port for good. selectAutoRecipeIfUnset(*consumer, item.type); if (!canAcceptInput(*consumer, j, item.type)) { return false; } depositToInputBelt(*consumer, j, item); return true; } return false; } void ProductionSystem::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; } if (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. if (building.production) { if (currentTick < building.production->completesAt) { continue; } for (const Item& item : building.production->chosenOutputs) { building.outputBuffer.items.push_back(item); } building.production = std::nullopt; // Fall through to the start attempt below rather than idling for a tick, // so a cycle takes exactly its recipe duration and a building fed to // capacity produces at the configured rate (REQ-MAT-CYCLE). The start // code runs once per building per tick, so at most one cycle begins here // even when a duration rounds to zero ticks. The outputs just deposited // count against the space check, so a cycle whose output no longer fits // waits, exactly as it would have on the following tick. } // Idle: try to start the building's one selected recipe. Every type holds // exactly one, a Smelter and a Reprocessing Plant included -- they differ only // in how theirs first got set (REQ-BLD-AUTO-RECIPE). const RecipeDef* recipe = getSelectedRecipe(m_config, building); if (!recipe) { continue; } // 1. All required inputs present? if (!recipeInputsAvailable(building, *recipe)) { continue; } // 2. Room for every output this cycle could produce -- checked before anything // is rolled (REQ-MAT-CYCLE). The roll below is committed the moment the cycle // starts, so a plant that could not store some outcome must not start at all: // that is what stops a stalled output belt from biasing the distribution // towards the outputs that still fit. Emerging items count against their // buffer (REQ-MAT-OUTPUT-EMERGE). The status light asks the same question to // decide yellow (REQ-UI-STATUS-LIGHT), so the test lives in one place. if (!recipeOutputsFit(building, *recipe)) { continue; } // 3. Settle what this cycle produces: its one output group, picked by weight only // where the recipe has several (REQ-MAT-OUTPUT-GROUP). Empty means every group // was ineligible, so there is nothing to run. std::vector chosen = rollOutputGroup(*recipe); if (chosen.empty()) { 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); } } void ProductionSystem::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) { continue; } 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; // Fall through and start the next cycle in this same tick, so a ship takes // exactly its computed production time (REQ-BLD-SHIPYARD), as for the // recipe buildings in tickProduction. } // 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 ProductionSystem::tickOutputBelts(FactoryState& state, BeltSystem& belts) { 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 = 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 (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()); } } } }