diff --git a/docs/architecture.md b/docs/architecture.md index 2bb4626..f9849f9 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -109,9 +109,11 @@ Within a single simulation tick, subsystems run in this fixed order. The order i 1. **Wave scheduler** — advance wave timer; on trigger, compute wave composition per REQ-WAV-TRIGGER and schedule spawn times across REQ-WAV-SPAWN-DURATION; spawn any enemy ships whose scheduled time has arrived this tick. 2. **Threat accumulation** — add `max(0, threat_rate_formula(t))` × tick_dt to threat level (REQ-WAV-THREAT-RATE). -3. **Belt → building pull** — buildings drain eligible items from adjacent belt tiles into per-material input buffers (REQ-MAT-INPUT-PORTS). -4. **Building production** — advance production timers; start new cycles when inputs and output-buffer space permit (REQ-MAT-CYCLE); on completion, deposit output. -5. **Building → belt push** — buildings push items from output buffer onto the belt tile at their output port (REQ-MAT-OUTPUT-PORT). +3. **Belt → building pull** — buildings drain eligible items from adjacent belt tiles into per-material input buffers (REQ-MAT-INPUT-PORTS). `ProductionSystem::tickBeltPull`. +4. **Building production** — advance production timers; start new cycles when inputs and output-buffer space permit (REQ-MAT-CYCLE); on completion, deposit output. `ProductionSystem::tickProduction`, then `tickShipyardProduction` for the shipyard's ship (REQ-BLD-SHIPYARD). +5. **Building → belt push** — buildings push items from output buffer onto the belt tile at their output port (REQ-MAT-OUTPUT-PORT). `ProductionSystem::tickOutputBelts`. + + Steps 3–5 are one system and must stay adjacent and in this order: an item arriving in step 3 is consumable in step 4, and an item produced in step 4 starts travelling in step 5. Step 4 is also the only place the factory draws from the RNG (an output group's weighted pick, REQ-LOCK-OUTPUT-POOL), so moving these calls relative to any other draw invalidates recorded replays. 6. **Belt tick** — advance items along belt tiles; apply splitter routing (REQ-BLD-SPLITTER). 7. **Ship behavior systems** — clear `MovementIntent` on each ship, then the `AiSystem` runs three batched phases: every behavior **evaluator** scores its behavior and sets its target data; a **selection** pass records the highest-scoring behavior per ship in `SelectedBehaviorComponent`; each behavior **executor** runs for the winner, writing `MovementIntent` and preferred module targets. The module systems then perform world mutation: `SalvagerSystem` (scrap collection/delivery) and `RepairSystem` (healing). See Movement Arbitration. 8. **Combat resolution** — ships and defence stations validate/acquire targets, fire, apply damage; queue deaths. Each fire appends a `BeamFiredEvent` to the sim's beam-fired-event queue (REQ-SHP-FIRING-BEAM). The repair and salvage module systems (tick step 7d) append their own `BeamFiredEvent`s to the same queue when they start a cycle. @@ -241,6 +243,7 @@ struct Building { - The uniform "input buffer → production timer → output buffer" pattern across miner, smelter, assembler, reprocessing plant, and shipyard is driven by the recipe config, not by a class hierarchy. - Belts and splitters are separate types owned by the belt subsystem, not general `Building` instances. - No ECS for buildings. A miner is never also an assembler; there is no composition benefit to decomposing buildings into components. +- **What buildings are is separate from what flows through them.** `BuildingSystem` places, demolishes, rotates and configures; `ProductionSystem` runs intake, production cycles and output (tick steps 3–5). The split follows what each needs: only the flow side draws from the RNG, tests unlock state, and spawns a ship into the entity model, so only it holds those. `BuildingSystem` holds the config and the belts and nothing else. ### Factory State and Queries @@ -259,8 +262,8 @@ struct FactoryState { }; ``` -Every system that touches the factory — `BuildingSystem`, `ConstructionSystem`, -`DeconstructionSystem` — takes it as an argument and holds none of it, the same shape the +Every system that touches the factory — `BuildingSystem`, `ProductionSystem`, +`ConstructionSystem`, `DeconstructionSystem` — takes it as an argument and holds none of it, the same shape the `lib/ecs/system` classes have, where the world arrives per tick. This is why `ConstructionSystem` can complete a building itself instead of handing the finished site back to `BuildingSystem`: with the state in the argument there is no owner to route diff --git a/src/balancing/ArenaSimulation.cpp b/src/balancing/ArenaSimulation.cpp index e2c930d..2741cc3 100644 --- a/src/balancing/ArenaSimulation.cpp +++ b/src/balancing/ArenaSimulation.cpp @@ -47,12 +47,9 @@ ArenaSimulation::ArenaSimulation(const GameConfig& gameConfig, { m_factoryState = makeFactoryState(m_gameConfig); - m_buildingSystem = std::make_unique( - m_gameConfig, - m_beltSystem, - [](const std::string&, QVector2D, const std::optional&) {}, - [](const std::string&) -> bool { return true; }, - m_rng); + // No ProductionSystem here: the arena stages ships directly and never runs a + // factory, so nothing ticks material flow (ProductionSystem.h). + m_buildingSystem = std::make_unique(m_gameConfig, m_beltSystem); m_shipSystem = std::make_unique(m_gameConfig, m_admin); // Arena fights are symmetric and aggressive: player-faction ships must not diff --git a/src/lib/sim/BuildingSystem.cpp b/src/lib/sim/BuildingSystem.cpp index 2d07ae0..a308d71 100644 --- a/src/lib/sim/BuildingSystem.cpp +++ b/src/lib/sim/BuildingSystem.cpp @@ -2,39 +2,17 @@ #include #include -#include -#include #include #include "FactoryQueries.h" #include "PlacementRules.h" -#include "ProductionRules.h" #include "PortGeometry.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&)> spawnShip, - std::function isItemUnlocked, - std::mt19937& rng) +BuildingSystem::BuildingSystem(const GameConfig& config, BeltSystem& belts) : m_config(config) , m_belts(belts) - , m_spawnShip(std::move(spawnShip)) - , m_isItemUnlocked(std::move(isItemUnlocked)) - , m_rng(rng) { } @@ -43,63 +21,7 @@ BuildingSystem::BuildingSystem(const GameConfig& config, // --------------------------------------------------------------------------- -namespace -{ -// 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 BuildingSystem::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))]); -} // --------------------------------------------------------------------------- // Placement @@ -376,397 +298,6 @@ void BuildingSystem::cancelDeconstruction(FactoryState& state, BuildingId id) } } -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) - { - 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 = m_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 = m_belts.tryTakeItem(building.inputPorts[i]); - if (taken) - { - depositToInputBelt(building, i, *taken); - } - } - } -} - -void BuildingSystem::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 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; } - - // 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 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; - } - - 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 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) - { - 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 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::rotateInPlace(FactoryState& state, BuildingId id, Rotation newRotation) { // Construction site path — just update rotation; no ports to recompute. diff --git a/src/lib/sim/BuildingSystem.h b/src/lib/sim/BuildingSystem.h index 4e753ad..dee248d 100644 --- a/src/lib/sim/BuildingSystem.h +++ b/src/lib/sim/BuildingSystem.h @@ -4,13 +4,11 @@ #include #include #include -#include #include #include #include #include -#include #include #include "BeltSystem.h" @@ -19,7 +17,6 @@ #include "BuildingBuffers.h" #include "DeconstructionSystem.h" #include "PlacementRules.h" -#include "ProductionRules.h" #include "BuildingType.h" #include "BuildingId.h" #include "GameConfig.h" @@ -29,19 +26,17 @@ #include "ShipsConfig.h" #include "Tick.h" -// Manages building placement, construction queuing, and the per-tick -// production loop (belt→building pull, production, building→belt push). -// All types including Belt and Splitter are stored as Building instances; -// BeltSystem owns the per-tile simulation data (item slots, flow). +// What buildings are: placing them, demolishing them, rotating them, and configuring +// what they will run. What flows through them once they stand -- intake, production +// cycles, output -- belongs to ProductionSystem, which took the RNG, the ship spawner and +// the unlock test with it. +// +// All types including Belt and Splitter are stored as Building instances; BeltSystem owns +// the per-tile simulation data (item slots, flow). class BuildingSystem { public: - BuildingSystem(const GameConfig& config, - BeltSystem& belts, - std::function&)> spawnShip, - std::function isItemUnlocked, - std::mt19937& rng); + BuildingSystem(const GameConfig& config, BeltSystem& belts); // -- Placement / deconstruct ------------------------------------------------ // Returns the new entity id, or nullopt if the placement falls outside the @@ -92,17 +87,9 @@ public: const std::vector& filterA, const std::vector& filterB); - // -- Tick hooks (called from Simulation::tick in the documented order) --- - // Advances every building's virtual input belts, delivers what arrives into the - // input buffers (into the global block stock for the HQ), and takes what the - // adjacent real belts offer (REQ-MAT-INPUT-INTAKE, REQ-HQ-BELT-INPUT). - void tickBeltPull(FactoryState& state); - void tickProduction(FactoryState& state, Tick currentTick); - void tickShipyardProduction(FactoryState& state, Tick currentTick); - // Advances each building's virtual output belts, hands finished items off onto - // the adjacent real belt, and feeds new buffered items into them - // (REQ-MAT-OUTPUT-EMERGE). - void tickOutputBelts(FactoryState& state); + // This system has no tick hook of its own: a building placed, configured or + // demolished is a player action, not something that advances every tick + // (ProductionSystem, ConstructionSystem, DeconstructionSystem tick instead). // This system answers no queries: reading the factory needs none, the queries being // free functions over the state (FactoryQueries.h), the placement rules @@ -133,46 +120,11 @@ public: void forEachBuilding(FactoryState& state, std::function fn); private: - // Selects a recipe for an auto-recipe building that has none, from a material being - // offered to it at one of its input ports (REQ-BLD-AUTO-RECIPE). No-op for every - // other building, for one that already holds a recipe, and for a material none of - // its recipes consumes. Called from both intake paths -- the belt pull and the - // direct coupling -- since either can be where the first material arrives. - void selectAutoRecipeIfUnset(Building& building, - const ItemType& offered); - // True if the consumer would accept `type` at the given input port right now: - // it is a required input (or a building block for the HQ), the reservation-aware - // buffer has room, and the input belt entry is free (REQ-MAT-INPUT-INTAKE). - bool canAcceptInput(const Building& consumer, - std::size_t inputPortIndex, - const ItemType& type) const; - // Places an accepted item onto the consumer's input belt at progress 0.0, - // reserving a per-material buffer slot (REQ-MAT-INPUT-INTAKE). - void depositToInputBelt(Building& consumer, - std::size_t inputPortIndex, - const Item& item); - // Attempts to hand an emerging output item straight into a directly adjacent - // building whose input edge meets the producer's output port (REQ-MAT-DIRECT-COUPLE). - // Returns true if the item was accepted onto the consumer's input belt. - bool tryDirectCoupleDeposit(FactoryState& state, BuildingId producerId, - const Port& outputPort, - const Item& item); - - // What one cycle of this recipe produces: the items of its one output group - // (REQ-MAT-OUTPUT-GROUP). Where the recipe has several, one is picked by weight from - // those currently eligible (REQ-LOCK-OUTPUT-POOL) and the result is empty if none is; - // where it has one, that group is returned with no draw and no eligibility test. - std::vector rollOutputGroup(const RecipeDef& recipe); - - // No world data among these: the factory arrives per call (FactoryState.h). What is - // left are the immutable config, the transport layer, the shared RNG, and two - // callbacks into what this system genuinely cannot reach -- the entity model a - // finished ship is spawned into, and the unlock state an output group is tested - // against. Neither is factory data, so neither belongs in the state. - const GameConfig& m_config; - BeltSystem& m_belts; - std::function&)> m_spawnShip; - std::function m_isItemUnlocked; - std::mt19937& m_rng; + // No world data here: the factory arrives per call (FactoryState.h). The config says + // what a building costs, occupies and can run; the belts are what a placed, rotated or + // demolished belt tile must be registered with and unregistered from. Nothing else -- + // no RNG and no callbacks, those having gone to ProductionSystem with the material + // flow that needed them. + const GameConfig& m_config; + BeltSystem& m_belts; }; diff --git a/src/lib/sim/CMakeLists.txt b/src/lib/sim/CMakeLists.txt index b3fd447..698ad76 100644 --- a/src/lib/sim/CMakeLists.txt +++ b/src/lib/sim/CMakeLists.txt @@ -18,6 +18,7 @@ SET(HDRS ${CMAKE_CURRENT_SOURCE_DIR}/BuildingBuffers.h ${CMAKE_CURRENT_SOURCE_DIR}/FactoryState.h ${CMAKE_CURRENT_SOURCE_DIR}/FactoryChecksum.h + ${CMAKE_CURRENT_SOURCE_DIR}/ProductionSystem.h ${CMAKE_CURRENT_SOURCE_DIR}/FactoryQueries.h ${CMAKE_CURRENT_SOURCE_DIR}/ProductionRules.h ${CMAKE_CURRENT_SOURCE_DIR}/PlacementRules.h @@ -50,6 +51,7 @@ SET(SRCS ${CMAKE_CURRENT_SOURCE_DIR}/DeconstructionSystem.cpp ${CMAKE_CURRENT_SOURCE_DIR}/BuildingBuffers.cpp ${CMAKE_CURRENT_SOURCE_DIR}/FactoryChecksum.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/ProductionSystem.cpp ${CMAKE_CURRENT_SOURCE_DIR}/FactoryQueries.cpp ${CMAKE_CURRENT_SOURCE_DIR}/ProductionRules.cpp ${CMAKE_CURRENT_SOURCE_DIR}/PlacementRules.cpp diff --git a/src/lib/sim/ProductionSystem.cpp b/src/lib/sim/ProductionSystem.cpp new file mode 100644 index 0000000..953a8b1 --- /dev/null +++ b/src/lib/sim/ProductionSystem.cpp @@ -0,0 +1,483 @@ +#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()); + } + } + } +} diff --git a/src/lib/sim/ProductionSystem.h b/src/lib/sim/ProductionSystem.h new file mode 100644 index 0000000..3c80ebf --- /dev/null +++ b/src/lib/sim/ProductionSystem.h @@ -0,0 +1,105 @@ +#pragma once + +#include +#include +#include +#include +#include +#include + +#include + +#include "Building.h" +#include "BuildingId.h" +#include "FactoryState.h" +#include "GameConfig.h" +#include "Item.h" +#include "ItemType.h" +#include "Port.h" +#include "ShipLayout.h" +#include "Tick.h" + +class BeltSystem; + +// The building side of material flow, end to end: what a building takes in, what it makes +// of it, and what it puts back out. Its four tick hooks are steps 3 to 5 of the tick order +// (docs/architecture.md), run back to back and in this order -- +// +// tickBeltPull belt -> building (REQ-MAT-INPUT-PORTS, REQ-MAT-INPUT-INTAKE) +// tickProduction one cycle (REQ-MAT-CYCLE, REQ-MAT-OUTPUT-GROUP) +// tickShipyardProduction one ship (REQ-BLD-SHIPYARD) +// tickOutputBelts building -> belt (REQ-MAT-OUTPUT-EMERGE, REQ-MAT-OUTPUT-PORT) +// +// -- so that an item arriving this tick is consumable this tick and a produced item starts +// travelling the same tick it appears. +// +// Split out of BuildingSystem, which keeps what buildings *are* -- placement, demolition, +// rotation, recipe and layout configuration. This system keeps what flows through them, +// and with it the three things that flow needs and building topology never did: the RNG an +// output group is drawn with, the unlock test that decides which groups are eligible, and +// the entity model a finished ship is spawned into. +// +// Holds no world data: the factory and the transport layer arrive per tick, as they do for +// ConstructionSystem (FactoryState.h). +class ProductionSystem +{ +public: + ProductionSystem(const GameConfig& config, + std::function&)> spawnShip, + std::function isItemUnlocked, + std::mt19937& rng); + + // Advances every building's virtual input belts, delivers what arrives into the input + // buffers (into the global block stock for the HQ), and takes what the adjacent real + // belts offer (REQ-MAT-INPUT-INTAKE, REQ-HQ-BELT-INPUT). + void tickBeltPull(FactoryState& state, BeltSystem& belts); + void tickProduction(FactoryState& state, Tick currentTick); + void tickShipyardProduction(FactoryState& state, Tick currentTick); + // Advances each building's virtual output belts, hands finished items off onto the + // adjacent real belt, and feeds new buffered items into them (REQ-MAT-OUTPUT-EMERGE). + void tickOutputBelts(FactoryState& state, BeltSystem& belts); + +private: + // Selects a recipe for an auto-recipe building that has none, from a material being + // offered to it at one of its input ports (REQ-BLD-AUTO-RECIPE). No-op for every + // other building, for one that already holds a recipe, and for a material none of + // its recipes consumes. Called from both intake paths -- the belt pull and the + // direct coupling -- since either can be where the first material arrives. + void selectAutoRecipeIfUnset(Building& building, + const ItemType& offered); + // True if the consumer would accept `type` at the given input port right now: + // it is a required input (or a building block for the HQ), the reservation-aware + // buffer has room, and the input belt entry is free (REQ-MAT-INPUT-INTAKE). + bool canAcceptInput(const Building& consumer, + std::size_t inputPortIndex, + const ItemType& type) const; + // Places an accepted item onto the consumer's input belt at progress 0.0, + // reserving a per-material buffer slot (REQ-MAT-INPUT-INTAKE). + void depositToInputBelt(Building& consumer, + std::size_t inputPortIndex, + const Item& item); + // Attempts to hand an emerging output item straight into a directly adjacent + // building whose input edge meets the producer's output port (REQ-MAT-DIRECT-COUPLE). + // Returns true if the item was accepted onto the consumer's input belt. + bool tryDirectCoupleDeposit(FactoryState& state, BuildingId producerId, + const Port& outputPort, + const Item& item); + + // What one cycle of this recipe produces: the items of its one output group + // (REQ-MAT-OUTPUT-GROUP). Where the recipe has several, one is picked by weight from + // those currently eligible (REQ-LOCK-OUTPUT-POOL) and the result is empty if none is; + // where it has one, that group is returned with no draw and no eligibility test. + std::vector rollOutputGroup(const RecipeDef& recipe); + + const GameConfig& m_config; + // Spawning a finished ship reaches into the entity model, and an output group's + // eligibility into the unlock state; neither is factory data, so both arrive as + // callbacks rather than living in FactoryState. + std::function&)> m_spawnShip; + std::function m_isItemUnlocked; + // The simulation's one RNG, by reference: a draw here shares the stream with every + // other draw in the run, which is what makes a replay reproducible (docs/replay_design.md). + std::mt19937& m_rng; +}; diff --git a/src/lib/sim/Simulation.cpp b/src/lib/sim/Simulation.cpp index ecf5f2b..90bd8f4 100644 --- a/src/lib/sim/Simulation.cpp +++ b/src/lib/sim/Simulation.cpp @@ -13,6 +13,7 @@ #include "AiSystem.h" #include "Command.h" #include "BuildingSystem.h" +#include "ProductionSystem.h" #include "CombatSystem.h" #include "DynamicBodyComponent.h" #include "DynamicBodySystem.h" @@ -107,9 +108,9 @@ void Simulation::reset(unsigned int seed) void Simulation::initializeSubsystems() { - m_buildingSystem = std::make_unique( + m_buildingSystem = std::make_unique(m_config, m_beltSystem); + m_productionSystem = std::make_unique( m_config, - m_beltSystem, [this](const std::string& id, QVector2D pos, const std::optional& layout) { if (!isSchematicUnlocked(id)) @@ -244,10 +245,10 @@ void Simulation::tick() // Construction + production pipeline m_constructionSystem->tick(m_factoryState, m_beltSystem, m_currentTick); m_deconstructionSystem->tick(m_factoryState, m_currentTick); // parallel to construction - m_buildingSystem->tickBeltPull(m_factoryState); // step 3 - m_buildingSystem->tickProduction(m_factoryState, m_currentTick); // step 4 - m_buildingSystem->tickShipyardProduction(m_factoryState, m_currentTick); // step 4b - m_buildingSystem->tickOutputBelts(m_factoryState); // step 5 + m_productionSystem->tickBeltPull(m_factoryState, m_beltSystem); // step 3 + m_productionSystem->tickProduction(m_factoryState, m_currentTick); // step 4 + m_productionSystem->tickShipyardProduction(m_factoryState, m_currentTick); // step 4b + m_productionSystem->tickOutputBelts(m_factoryState, m_beltSystem); // step 5 m_beltSystem.tick(); // step 6 // Step 7: ship behavior systems (movement arbitration via intent priority) diff --git a/src/lib/sim/Simulation.h b/src/lib/sim/Simulation.h index 75f9e20..ab8fc0c 100644 --- a/src/lib/sim/Simulation.h +++ b/src/lib/sim/Simulation.h @@ -27,6 +27,7 @@ class AiSystem; class BuildingSystem; class ConstructionSystem; class DeconstructionSystem; +class ProductionSystem; struct Command; class Hasher; class CombatSystem; @@ -218,6 +219,7 @@ private: FactoryState m_factoryState; BeltSystem m_beltSystem; std::unique_ptr m_buildingSystem; + std::unique_ptr m_productionSystem; std::unique_ptr m_constructionSystem; std::unique_ptr m_deconstructionSystem; std::unique_ptr m_shipSystem; diff --git a/src/test/BehaviorSystemTest.cpp b/src/test/BehaviorSystemTest.cpp index 9806023..fdfcc17 100644 --- a/src/test/BehaviorSystemTest.cpp +++ b/src/test/BehaviorSystemTest.cpp @@ -77,10 +77,7 @@ struct Fixture : cfg(loadTestConfig()) , belts(cfg.world.beltSpeed_tps) , rng(42) - , buildings(cfg, belts, - [](const std::string&, QVector2D, const std::optional&) {}, - [](const std::string&) -> bool { return true; }, - rng) + , buildings(cfg, belts) , construction(cfg) , ships(cfg, admin) , ai(cfg) diff --git a/src/test/BuildingTest.cpp b/src/test/BuildingTest.cpp index f68c20a..1479b70 100644 --- a/src/test/BuildingTest.cpp +++ b/src/test/BuildingTest.cpp @@ -17,6 +17,7 @@ #include "Building.h" #include "BuildingBuffers.h" #include "BuildingSystem.h" +#include "ProductionSystem.h" #include "ConstructionSystem.h" #include "DeconstructionSystem.h" #include "FactoryState.h" @@ -57,16 +58,16 @@ static Port westPort(QPoint tile) } // Run N full sim ticks: construction, belt-pull, production, belt-push, belt tick. -static void runTicks(BuildingSystem& bs, const GameConfig& cfg, FactoryState& state_bs, - BeltSystem& belts, int n, Tick& tick) +static void runTicks(ProductionSystem& production, const GameConfig& cfg, + FactoryState& state_bs, BeltSystem& belts, int n, Tick& tick) { for (int i = 0; i < n; ++i) { ConstructionSystem(cfg).tick(state_bs, belts, tick); DeconstructionSystem(cfg).tick(state_bs, tick); - bs.tickBeltPull(state_bs); - bs.tickProduction(state_bs, tick); - bs.tickOutputBelts(state_bs); + production.tickBeltPull(state_bs, belts); + production.tickProduction(state_bs, tick); + production.tickOutputBelts(state_bs, belts); belts.tick(); ++tick; } @@ -99,7 +100,8 @@ struct PlacementFixture FactoryState state = makeFactoryState(cfg); BeltSystem belts; std::mt19937 rng{0}; - BuildingSystem bs; + BuildingSystem bs; + ProductionSystem production; // Blocks credited back since the run began. The state is seeded with the configured // starting stock (FactoryState.h), so a refund reads as a delta rather than a total. @@ -116,7 +118,8 @@ struct PlacementFixture std::optional beltSpeed_tps = std::nullopt, std::function isItemUnlocked = nullptr) : belts(beltSpeed_tps.value_or(cfg.world.beltSpeed_tps)) - , bs(cfg, belts, + , bs(cfg, belts) + , production(cfg, [](const std::string&, QVector2D, const std::optional&) {}, isItemUnlocked ? std::move(isItemUnlocked) : std::function( @@ -258,7 +261,7 @@ TEST_CASE("BuildingSystem: placing a belt registers it with BeltSystem after con // Complete construction (1 s). Tick tick = 0; - runTicks(f.bs, f.cfg, f.state, f.belts, static_cast(secondsToTicks(1.0)) + 1, tick); + runTicks(f.production, f.cfg, f.state, f.belts, static_cast(secondsToTicks(1.0)) + 1, tick); REQUIRE(f.belts.tryPutItem(QPoint(5, 5), makeItem("iron_ore"), Rotation::East)); REQUIRE(getAllBuildings(f.state).size() == 1); @@ -327,7 +330,7 @@ TEST_CASE("BuildingSystem: construction completes after configured duration", "[ // Miner construction_time_seconds = 10. completesAt = secondsToTicks(10) = 300. // We need to process tick 300 itself, so run 301 ticks (ticks 0..300). Tick tick = 0; - runTicks(f.bs, f.cfg, f.state, f.belts, static_cast(secondsToTicks(10.0)) + 1, tick); + runTicks(f.production, f.cfg, f.state, f.belts, static_cast(secondsToTicks(10.0)) + 1, tick); REQUIRE(getAllSites(f.state).empty()); REQUIRE(findBuilding(f.state, id) != nullptr); @@ -342,7 +345,7 @@ static void runUntilBuilt(PlacementFixture& f, BuildingId id, Tick& tick) { for (int i = 0; i < 100000 && findBuilding(f.state, id) == nullptr; ++i) { - runTicks(f.bs, f.cfg, f.state, f.belts, 1, tick); + runTicks(f.production, f.cfg, f.state, f.belts, 1, tick); } REQUIRE(findBuilding(f.state, id) != nullptr); } @@ -366,7 +369,7 @@ TEST_CASE("BuildingSystem: deconstructing a built building queues it; refund cre // After the deconstruction time (0.1s = 3 ticks) it is removed and the partial // refund (15 * 75 / 100 = 11) is credited exactly once. - runTicks(f.bs, f.cfg, f.state, f.belts, static_cast(secondsToTicks(0.1)) + 1, tick); + runTicks(f.production, f.cfg, f.state, f.belts, static_cast(secondsToTicks(0.1)) + 1, tick); REQUIRE(findBuilding(f.state, id) == nullptr); REQUIRE_FALSE(isTileOccupied(f.state, QPoint(0, 0))); REQUIRE(f.getRefundedBlocks() == 15 * f.cfg.world.refundPercentage / 100); @@ -390,14 +393,14 @@ TEST_CASE("BuildingSystem: deconstruction queue removes one building at a time", // After one deconstruction interval only the front building is gone; the // second is still queued and its refund not yet credited. - runTicks(f.bs, f.cfg, f.state, f.belts, static_cast(secondsToTicks(0.1)) + 1, tick); + runTicks(f.production, f.cfg, f.state, f.belts, static_cast(secondsToTicks(0.1)) + 1, tick); REQUIRE(findBuilding(f.state, a) == nullptr); REQUIRE(findBuilding(f.state, b) != nullptr); REQUIRE(isQueuedForDeconstruction(f.state, b)); REQUIRE(f.getRefundedBlocks() == 15 * f.cfg.world.refundPercentage / 100); // The second drains next. - runTicks(f.bs, f.cfg, f.state, f.belts, static_cast(secondsToTicks(0.1)) + 2, tick); + runTicks(f.production, f.cfg, f.state, f.belts, static_cast(secondsToTicks(0.1)) + 2, tick); REQUIRE(findBuilding(f.state, b) == nullptr); REQUIRE(f.getRefundedBlocks() == 2 * (15 * f.cfg.world.refundPercentage / 100)); } @@ -423,7 +426,7 @@ TEST_CASE("BuildingSystem: cancelling deconstruction resumes the building with n REQUIRE(f.getRefundedBlocks() == 0); // It is never removed even after more than a deconstruction interval passes. - runTicks(f.bs, f.cfg, f.state, f.belts, static_cast(secondsToTicks(0.1)) + 5, tick); + runTicks(f.production, f.cfg, f.state, f.belts, static_cast(secondsToTicks(0.1)) + 5, tick); REQUIRE(findBuilding(f.state, id) != nullptr); REQUIRE(f.getRefundedBlocks() == 0); } @@ -482,7 +485,7 @@ TEST_CASE("BuildingSystem: second building starts after first completes", "[buil // Process through tick 300 to complete first miner's construction. Tick tick = 0; - runTicks(f.bs, f.cfg, f.state, f.belts, static_cast(secondsToTicks(10.0)) + 1, tick); + runTicks(f.production, f.cfg, f.state, f.belts, static_cast(secondsToTicks(10.0)) + 1, tick); REQUIRE(getAllSites(f.state).size() == 1); REQUIRE(getAllSites(f.state).front().id == id2); @@ -503,7 +506,7 @@ TEST_CASE("BuildingSystem: miner produces iron_ore after recipe duration", "[bui Tick tick = 0; // Construction completes on tick 300; production cycle starts tick 300, // completes on tick 330. Process through tick 330: 331 ticks total. - runTicks(f.bs, f.cfg, f.state, f.belts, + runTicks(f.production, f.cfg, f.state, f.belts, static_cast(secondsToTicks(10.0)) + static_cast(secondsToTicks(1.0)) + 1, tick); @@ -529,7 +532,7 @@ TEST_CASE("BuildingSystem: miner output buffer stalls when full", "[building]") // (completesAt=360). Cycle 2 completes at tick 360: deposit item -> 2 items held, // which fills the buffer (capacity 2), so cycle 3 cannot start. // Need to process through tick 360: 361 ticks total. - runTicks(f.bs, f.cfg, f.state, f.belts, + runTicks(f.production, f.cfg, f.state, f.belts, static_cast(secondsToTicks(10.0)) + 2 * static_cast(secondsToTicks(1.0)) + 1, tick); @@ -558,7 +561,7 @@ TEST_CASE("BuildingSystem: the next cycle starts on the tick the last one comple Tick tick = 0; // Construction completes at tick 300 and cycle 1 starts in that same tick. - runTicks(f.bs, f.cfg, f.state, f.belts, + runTicks(f.production, f.cfg, f.state, f.belts, static_cast(secondsToTicks(10.0)) + 1, tick); const Building* b = findBuilding(f.state, id); REQUIRE(b != nullptr); @@ -567,7 +570,7 @@ TEST_CASE("BuildingSystem: the next cycle starts on the tick the last one comple // Process up to and including that completion tick: the next cycle is already // running, due exactly one duration later rather than one duration plus a tick. - runTicks(f.bs, f.cfg, f.state, f.belts, static_cast(cycleTicks), tick); + runTicks(f.production, f.cfg, f.state, f.belts, static_cast(cycleTicks), tick); b = findBuilding(f.state, id); REQUIRE(b->getOutputItemCount() == 1); REQUIRE(b->production.has_value()); @@ -579,7 +582,7 @@ TEST_CASE("BuildingSystem: the next cycle starts on the tick the last one comple building.outputBuffer.items.clear(); for (std::vector& lane : building.emergingItems) { lane.clear(); } }); - runTicks(f.bs, f.cfg, f.state, f.belts, static_cast(cycleTicks), tick); + runTicks(f.production, f.cfg, f.state, f.belts, static_cast(cycleTicks), tick); b = findBuilding(f.state, id); REQUIRE(b->production.has_value()); REQUIRE(b->production->completesAt == firstCompletesAt + 2 * cycleTicks); @@ -603,10 +606,10 @@ TEST_CASE("BuildingSystem: productionBuildingCount excludes construction sites", // The queue builds one at a time: miner (10s) completes at tick 300, then // the smelter (15s) starts and completes at tick 300 + 450 = 750. - runTicks(f.bs, f.cfg, f.state, f.belts, static_cast(secondsToTicks(10.0)) + 1, tick); + runTicks(f.production, f.cfg, f.state, f.belts, static_cast(secondsToTicks(10.0)) + 1, tick); REQUIRE(getProductionBuildingCount(f.state) == 1); - runTicks(f.bs, f.cfg, f.state, f.belts, static_cast(secondsToTicks(15.0)), tick); + runTicks(f.production, f.cfg, f.state, f.belts, static_cast(secondsToTicks(15.0)), tick); REQUIRE(getProductionBuildingCount(f.state) == 2); // Neither is producing yet: the miner has no recipe selected, and the @@ -614,7 +617,7 @@ TEST_CASE("BuildingSystem: productionBuildingCount excludes construction sites", REQUIRE(getActiveProductionBuildingCount(f.state) == 0); f.bs.setRecipe(f.state, minerId, "mine_iron_ore"); - runTicks(f.bs, f.cfg, f.state, f.belts, 1, tick); + runTicks(f.production, f.cfg, f.state, f.belts, 1, tick); REQUIRE(getActiveProductionBuildingCount(f.state) == 1); } @@ -631,12 +634,12 @@ TEST_CASE("BuildingSystem: activeProductionBuildingCount tracks production cycle REQUIRE(getActiveProductionBuildingCount(f.state) == 0); // Construction completes at tick 300; cycle 1 starts the same tick (completesAt=330). - runTicks(f.bs, f.cfg, f.state, f.belts, static_cast(secondsToTicks(10.0)) + 1, tick); + runTicks(f.production, f.cfg, f.state, f.belts, static_cast(secondsToTicks(10.0)) + 1, tick); REQUIRE(getActiveProductionBuildingCount(f.state) == 1); // Run cycles 1 and 2 to completion (1s each); cycle 3 stalls once the // output buffer (capacity 2) is full (REQ-MAT-OUTPUT-BUFFER). - runTicks(f.bs, f.cfg, f.state, f.belts, 2 * static_cast(secondsToTicks(1.0)) + 1, tick); + runTicks(f.production, f.cfg, f.state, f.belts, 2 * static_cast(secondsToTicks(1.0)) + 1, tick); const Building* b = findBuilding(f.state, id); REQUIRE(b != nullptr); @@ -663,7 +666,7 @@ TEST_CASE("BuildingSystem: smelter input buffer fills from adjacent west-flowing // Complete construction (15s → tick 450+1 = 451 ticks). Tick tick = 0; - runTicks(f.bs, f.cfg, f.state, f.belts, static_cast(secondsToTicks(15.0)) + 1, tick); + runTicks(f.production, f.cfg, f.state, f.belts, static_cast(secondsToTicks(15.0)) + 1, tick); REQUIRE(findBuilding(f.state, sid)->recipeId.empty()); // Place west-flowing belt at (2,0): belt flows West, delivers to smelter. @@ -671,7 +674,7 @@ TEST_CASE("BuildingSystem: smelter input buffer fills from adjacent west-flowing f.belts.tryPutItem(QPoint(2, 0), makeItem("iron_ore")); f.belts.tick(); - f.bs.tickBeltPull(f.state); + f.production.tickBeltPull(f.state, f.belts); const Building* b = findBuilding(f.state, sid); REQUIRE(b != nullptr); @@ -697,12 +700,12 @@ TEST_CASE("BuildingSystem: accepted input travels inward before entering the buf const BuildingId sid = f.bs.place(f.state, BuildingType::Smelter, QPoint(0, 0), Rotation::East, 0).value(); Tick tick = 0; - runTicks(f.bs, f.cfg, f.state, f.belts, static_cast(secondsToTicks(15.0)) + 1, tick); + runTicks(f.production, f.cfg, f.state, f.belts, static_cast(secondsToTicks(15.0)) + 1, tick); f.belts.placeBelt(QPoint(2, 0), Rotation::West); f.belts.tryPutItem(QPoint(2, 0), makeItem("iron_ore")); f.belts.tick(); - f.bs.tickBeltPull(f.state); // accepts the item onto the input belt at progress 0.0 + f.production.tickBeltPull(f.state, f.belts); // accepts the item onto the input belt at progress 0.0 const Building* b = findBuilding(f.state, sid); REQUIRE(b != nullptr); @@ -714,7 +717,7 @@ TEST_CASE("BuildingSystem: accepted input travels inward before entering the buf REQUIRE(b->pendingInputCount(ItemType{"iron_ore"}) == 1); // One more pull tick advances the input belt to the centre; the item arrives. - f.bs.tickBeltPull(f.state); + f.production.tickBeltPull(f.state, f.belts); REQUIRE(b->inputBuffer.counts.at(ItemType{"iron_ore"}) == 1); REQUIRE(b->pendingInputCount(ItemType{"iron_ore"}) == 1); } @@ -729,7 +732,7 @@ TEST_CASE("BuildingSystem: input reservation caps buffered plus in-transit at th const BuildingId id = f.bs.place(f.state, BuildingType::ReprocessingPlant, QPoint(0, 0), Rotation::East, 0).value(); Tick tick = 0; - runTicks(f.bs, f.cfg, f.state, f.belts, static_cast(secondsToTicks(25.0)) + 1, tick); + runTicks(f.production, f.cfg, f.state, f.belts, static_cast(secondsToTicks(25.0)) + 1, tick); // Feed scrap via an input belt without ever running production (only pull), so // the buffer fills and stays full. Try to over-fill it well past the cap. @@ -738,7 +741,7 @@ TEST_CASE("BuildingSystem: input reservation caps buffered plus in-transit at th { f.belts.tryPutItem(QPoint(-1, 0), makeItem("scrap"), Rotation::East); f.belts.tick(); - f.bs.tickBeltPull(f.state); + f.production.tickBeltPull(f.state, f.belts); } const Building* b = findBuilding(f.state, id); @@ -761,7 +764,7 @@ TEST_CASE("BuildingSystem: smelter auto-smelts ore without a recipe selection", const BuildingId sid = f.bs.place(f.state, BuildingType::Smelter, QPoint(0, 0), Rotation::East, 0).value(); Tick tick = 0; - runTicks(f.bs, f.cfg, f.state, f.belts, static_cast(secondsToTicks(15.0)) + 1, tick); + runTicks(f.production, f.cfg, f.state, f.belts, static_cast(secondsToTicks(15.0)) + 1, tick); // Feed 2 iron_ore (the test-config iron_ingot recipe needs 2) via a // west-flowing belt at input port (2,0). @@ -770,11 +773,11 @@ TEST_CASE("BuildingSystem: smelter auto-smelts ore without a recipe selection", { f.belts.tryPutItem(QPoint(2, 0), makeItem("iron_ore")); f.belts.tick(); - f.bs.tickBeltPull(f.state); + f.production.tickBeltPull(f.state, f.belts); } // iron_ingot recipe cycle is 2s; run to completion. - runTicks(f.bs, f.cfg, f.state, f.belts, static_cast(secondsToTicks(2.0)) + 2, tick); + runTicks(f.production, f.cfg, f.state, f.belts, static_cast(secondsToTicks(2.0)) + 2, tick); const Building* b = findBuilding(f.state, sid); REQUIRE(b != nullptr); @@ -797,7 +800,7 @@ TEST_CASE("BuildingSystem: mixed ore on one belt leaves the smelter on the first const BuildingId sid = f.bs.place(f.state, BuildingType::Smelter, QPoint(0, 0), Rotation::East, 0).value(); Tick tick = 0; - runTicks(f.bs, f.cfg, f.state, f.belts, static_cast(secondsToTicks(15.0)) + 1, tick); + runTicks(f.production, f.cfg, f.state, f.belts, static_cast(secondsToTicks(15.0)) + 1, tick); // Feed 1 iron_ore, then 2 copper_ore, via the west-flowing input belt. f.belts.placeBelt(QPoint(2, 0), Rotation::West); @@ -806,10 +809,10 @@ TEST_CASE("BuildingSystem: mixed ore on one belt leaves the smelter on the first { f.belts.tryPutItem(QPoint(2, 0), makeItem(id)); f.belts.tick(); - f.bs.tickBeltPull(f.state); + f.production.tickBeltPull(f.state, f.belts); } - runTicks(f.bs, f.cfg, f.state, f.belts, static_cast(secondsToTicks(2.5)) + 2, tick); + runTicks(f.production, f.cfg, f.state, f.belts, static_cast(secondsToTicks(2.5)) + 2, tick); const Building* b = findBuilding(f.state, sid); REQUIRE(b != nullptr); @@ -847,13 +850,13 @@ TEST_CASE("BuildingSystem: miner output buffer drains onto adjacent belt", "[bui Tick tick = 0; // Construction (10s) + 1 production cycle (1s) + 1 extra tick. - runTicks(f.bs, f.cfg, f.state, f.belts, + runTicks(f.production, f.cfg, f.state, f.belts, static_cast(secondsToTicks(10.0)) + static_cast(secondsToTicks(1.0)) + 1, tick); // Item should have been pushed onto the belt this tick or a subsequent one. // Run one more tick to ensure tickBeltPush fires after the deposit tick. - runTicks(f.bs, f.cfg, f.state, f.belts, 1, tick); + runTicks(f.production, f.cfg, f.state, f.belts, 1, tick); const std::optional item = f.belts.tryTakeItem(eastPort(QPoint(1, 1))); REQUIRE(item.has_value()); @@ -878,7 +881,7 @@ TEST_CASE("BuildingSystem: output port couples directly into an adjacent input p Tick tick = 0; // Smelter build (15s) + margin for coupling and a smelt cycle. - runTicks(f.bs, f.cfg, f.state, f.belts, static_cast(secondsToTicks(30.0)), tick); + runTicks(f.production, f.cfg, f.state, f.belts, static_cast(secondsToTicks(30.0)), tick); const Building* smelter = findBuilding(f.state, smelterId); REQUIRE(smelter != nullptr); @@ -907,7 +910,7 @@ TEST_CASE("BuildingSystem: direct coupling to a non-consumer leaves the item stu Tick tick = 0; // Both miners build sequentially (10s each), then the producer runs and jams. - runTicks(f.bs, f.cfg, f.state, f.belts, static_cast(secondsToTicks(25.0)), tick); + runTicks(f.production, f.cfg, f.state, f.belts, static_cast(secondsToTicks(25.0)), tick); const Building* miner = findBuilding(f.state, minerId); const Building* sink = findBuilding(f.state, sinkId); @@ -933,7 +936,7 @@ TEST_CASE("BuildingSystem: setRecipe clears output buffer and active production" Tick tick = 0; // Run until first item is in output buffer. - runTicks(f.bs, f.cfg, f.state, f.belts, + runTicks(f.production, f.cfg, f.state, f.belts, static_cast(secondsToTicks(10.0)) + static_cast(secondsToTicks(1.0)) + 1, tick); @@ -998,8 +1001,8 @@ TEST_CASE("BuildingSystem: a single-group recipe consumes no randomness", "[buil advanced.bs.setRecipe(advanced.state, b, "mine_iron_ore"); const int ticks = static_cast(secondsToTicks(10.0)) + 40; - runTicks(quiet.bs, quiet.cfg, quiet.state, quiet.belts, ticks, tickA); - runTicks(advanced.bs, advanced.cfg, advanced.state, advanced.belts, ticks, tickB); + runTicks(quiet.production, quiet.cfg, quiet.state, quiet.belts, ticks, tickA); + runTicks(advanced.production, advanced.cfg, advanced.state, advanced.belts, ticks, tickB); const Building* minerA = findBuilding(quiet.state, a); const Building* minerB = findBuilding(advanced.state, b); @@ -1056,7 +1059,7 @@ TEST_CASE("BuildingSystem: a group with a locked item is never picked", "[buildi Tick tick = 0; const BuildingId id = f.bs.place(f.state, BuildingType::ReprocessingPlant, QPoint(0, 0), Rotation::East, 0).value(); - runTicks(f.bs, f.cfg, f.state, f.belts, + runTicks(f.production, f.cfg, f.state, f.belts, static_cast(secondsToTicks(25.0)) + 1, tick); f.bs.setRecipe(f.state, id, "reprocessing_cycle"); @@ -1072,7 +1075,7 @@ TEST_CASE("BuildingSystem: a group with a locked item is never picked", "[buildi building.outputBuffer.items.clear(); for (std::vector& lane : building.emergingItems) { lane.clear(); } }); - runTicks(f.bs, f.cfg, f.state, f.belts, + runTicks(f.production, f.cfg, f.state, f.belts, static_cast(secondsToTicks(3.0)) + 1, tick); for (const Item& item : outputSideItems(*findBuilding(f.state, id))) @@ -1094,7 +1097,7 @@ TEST_CASE("BuildingSystem: reprocessing plant sizes one output buffer per possib // Complete construction (25s). Tick tick = 0; - runTicks(f.bs, f.cfg, f.state, f.belts, static_cast(secondsToTicks(25.0)) + 1, tick); + runTicks(f.production, f.cfg, f.state, f.belts, static_cast(secondsToTicks(25.0)) + 1, tick); // A plant holds no buffers until it has a recipe (REQ-BLD-AUTO-RECIPE); selecting // one sizes them, exactly as the first scrap offered to it would. @@ -1124,7 +1127,7 @@ TEST_CASE("BuildingSystem: one full output buffer stops the plant even when the const BuildingId id = f.bs.place(f.state, BuildingType::ReprocessingPlant, QPoint(0, 0), Rotation::East, 0).value(); Tick tick = 0; - runTicks(f.bs, f.cfg, f.state, f.belts, + runTicks(f.production, f.cfg, f.state, f.belts, static_cast(secondsToTicks(25.0)) + 1, tick); // Feed a full cycle's scrap (5) so only the output side can hold it back. @@ -1133,7 +1136,7 @@ TEST_CASE("BuildingSystem: one full output buffer stops the plant even when the { f.belts.tryPutItem(QPoint(-1, 0), makeItem("scrap"), Rotation::East); f.belts.tick(); - f.bs.tickBeltPull(f.state); + f.production.tickBeltPull(f.state, f.belts); } // Fill the iron_ingot buffer to its cap and leave the other two empty. @@ -1146,7 +1149,7 @@ TEST_CASE("BuildingSystem: one full output buffer stops the plant even when the } }); - runTicks(f.bs, f.cfg, f.state, f.belts, 5, tick); + runTicks(f.production, f.cfg, f.state, f.belts, 5, tick); const Building* b = findBuilding(f.state, id); REQUIRE(b != nullptr); @@ -1171,7 +1174,7 @@ TEST_CASE("BuildingSystem: reprocessing plant runs a second cycle while holding const BuildingId id = f.bs.place(f.state, BuildingType::ReprocessingPlant, QPoint(0, 0), Rotation::East, 0).value(); Tick tick = 0; - runTicks(f.bs, f.cfg, f.state, f.belts, + runTicks(f.production, f.cfg, f.state, f.belts, static_cast(secondsToTicks(25.0)) + 1, tick); // Two cycles' worth of scrap (5 each), which is exactly the input cap. @@ -1180,13 +1183,13 @@ TEST_CASE("BuildingSystem: reprocessing plant runs a second cycle while holding { f.belts.tryPutItem(QPoint(-1, 0), makeItem("scrap"), Rotation::East); f.belts.tick(); - f.bs.tickBeltPull(f.state); + f.production.tickBeltPull(f.state, f.belts); } REQUIRE(findBuilding(f.state, id)->pendingInputCount(ItemType{"scrap"}) == 10); // No belt carries the output away, so the first cycle's result is still held. // reprocessing_cycle runs 3s; run through the completion tick. - runTicks(f.bs, f.cfg, f.state, f.belts, + runTicks(f.production, f.cfg, f.state, f.belts, static_cast(secondsToTicks(3.0)) + 1, tick); const Building* b = findBuilding(f.state, id); @@ -1207,7 +1210,7 @@ static BuildingId buildSmelter(PlacementFixture& f, QPoint anchor, Tick& tick) { const BuildingId id = f.bs.place(f.state, BuildingType::Smelter, anchor, Rotation::East, 0).value(); - runTicks(f.bs, f.cfg, f.state, f.belts, + runTicks(f.production, f.cfg, f.state, f.belts, static_cast(secondsToTicks(15.0)) + 1, tick); return id; } @@ -1241,13 +1244,13 @@ TEST_CASE("BuildingSystem: a set recipe is never replaced by a later material", f.belts.placeBelt(QPoint(2, 0), Rotation::West); f.belts.tryPutItem(QPoint(2, 0), makeItem("iron_ore")); f.belts.tick(); - f.bs.tickBeltPull(f.state); + f.production.tickBeltPull(f.state, f.belts); REQUIRE(findBuilding(f.state, id)->recipeId == "iron_ingot"); // Copper ore next: refused, and the recipe stands. f.belts.tryPutItem(QPoint(2, 0), makeItem("copper_ore")); f.belts.tick(); - f.bs.tickBeltPull(f.state); + f.production.tickBeltPull(f.state, f.belts); const Building* b = findBuilding(f.state, id); REQUIRE(b->recipeId == "iron_ingot"); @@ -1270,7 +1273,7 @@ TEST_CASE("BuildingSystem: a manually selected recipe is not overridden", "[buil f.belts.placeBelt(QPoint(2, 0), Rotation::West); f.belts.tryPutItem(QPoint(2, 0), makeItem("iron_ore")); f.belts.tick(); - f.bs.tickBeltPull(f.state); + f.production.tickBeltPull(f.state, f.belts); const Building* b = findBuilding(f.state, id); REQUIRE(b->recipeId == "copper_ingot"); @@ -1291,8 +1294,8 @@ TEST_CASE("BuildingSystem: selecting a different recipe frees a stuck auto-recip f.belts.placeBelt(QPoint(2, 0), Rotation::West); f.belts.tryPutItem(QPoint(2, 0), makeItem("iron_ore")); f.belts.tick(); - f.bs.tickBeltPull(f.state); - runTicks(f.bs, f.cfg, f.state, f.belts, 30, tick); + f.production.tickBeltPull(f.state, f.belts); + runTicks(f.production, f.cfg, f.state, f.belts, 30, tick); const Building* stuck = findBuilding(f.state, id); REQUIRE(stuck->recipeId == "iron_ingot"); @@ -1322,7 +1325,7 @@ TEST_CASE("BuildingSystem: selecting (Auto) returns the building to automatic se f.belts.placeBelt(QPoint(2, 0), Rotation::West); f.belts.tryPutItem(QPoint(2, 0), makeItem("iron_ore")); f.belts.tick(); - f.bs.tickBeltPull(f.state); + f.production.tickBeltPull(f.state, f.belts); REQUIRE(findBuilding(f.state, id)->recipeId == "iron_ingot"); } @@ -1340,7 +1343,7 @@ TEST_CASE("BuildingSystem: reprocessing plant produces one cycle output then sta // Complete construction (25s). Tick tick = 0; - runTicks(f.bs, f.cfg, f.state, f.belts, static_cast(secondsToTicks(25.0)) + 1, tick); + runTicks(f.production, f.cfg, f.state, f.belts, static_cast(secondsToTicks(25.0)) + 1, tick); // Feed 5 scrap into the building via a belt at an input port. // Reprocessing plant body (East rotation) = 3×3 at (0,0). @@ -1350,7 +1353,7 @@ TEST_CASE("BuildingSystem: reprocessing plant produces one cycle output then sta { f.belts.tryPutItem(QPoint(-1, 0), makeItem("scrap"), Rotation::East); f.belts.tick(); - f.bs.tickBeltPull(f.state); + f.production.tickBeltPull(f.state, f.belts); } // Verify all five scrap were accepted; some may still be travelling inward on @@ -1362,7 +1365,7 @@ TEST_CASE("BuildingSystem: reprocessing plant produces one cycle output then sta } // Run production cycle (3s = 90 ticks + 1 for the completion tick). - runTicks(f.bs, f.cfg, f.state, f.belts, static_cast(secondsToTicks(3.0)) + 1, tick); + runTicks(f.production, f.cfg, f.state, f.belts, static_cast(secondsToTicks(3.0)) + 1, tick); const Building* b = findBuilding(f.state, id); REQUIRE(b != nullptr); @@ -1406,7 +1409,7 @@ TEST_CASE("BuildingSystem: findRotateInPlaceTarget returns the building id for a const BuildingId id = f.bs.place(f.state, BuildingType::Belt, QPoint(0, 0), Rotation::East, 0).value(); Tick tick = 0; - runTicks(f.bs, f.cfg, f.state, f.belts, static_cast(secondsToTicks(1.0)) + 1, tick); + runTicks(f.production, f.cfg, f.state, f.belts, static_cast(secondsToTicks(1.0)) + 1, tick); REQUIRE(getAllSites(f.state).empty()); const std::optional result = @@ -1806,7 +1809,7 @@ TEST_CASE("BuildingSystem: rotateInPlace updates rotation and output port direct const BuildingId id = f.bs.place(f.state, BuildingType::Belt, QPoint(0, 0), Rotation::East, 0).value(); Tick tick = 0; - runTicks(f.bs, f.cfg, f.state, f.belts, static_cast(secondsToTicks(1.0)) + 1, tick); + runTicks(f.production, f.cfg, f.state, f.belts, static_cast(secondsToTicks(1.0)) + 1, tick); REQUIRE(findBuilding(f.state, id) != nullptr); const Building& before = *findBuilding(f.state, id); @@ -1827,7 +1830,7 @@ TEST_CASE("BuildingSystem: rotateInPlace re-registers a belt tile with BeltSyste const BuildingId id = f.bs.place(f.state, BuildingType::Belt, QPoint(0, 0), Rotation::East, 0).value(); Tick tick = 0; - runTicks(f.bs, f.cfg, f.state, f.belts, static_cast(secondsToTicks(1.0)) + 1, tick); + runTicks(f.production, f.cfg, f.state, f.belts, static_cast(secondsToTicks(1.0)) + 1, tick); f.bs.rotateInPlace(f.state, id, Rotation::North); @@ -1847,7 +1850,7 @@ TEST_CASE("BuildingSystem: rotateInPlace preserves the output filters of a split Tick tick = 0; while (getAllBuildings(f.state).empty() && tick < 100000) { - runTicks(f.bs, f.cfg, f.state, f.belts, 1, tick); + runTicks(f.production, f.cfg, f.state, f.belts, 1, tick); } REQUIRE(getAllBuildings(f.state).size() == 1); @@ -1892,7 +1895,7 @@ TEST_CASE("BuildingSystem: splitter filters configured on a construction site ca Tick tick = 0; while (getAllBuildings(f.state).empty() && tick < 100000) { - runTicks(f.bs, f.cfg, f.state, f.belts, 1, tick); + runTicks(f.production, f.cfg, f.state, f.belts, 1, tick); } REQUIRE(getAllBuildings(f.state).size() == 1); REQUIRE(getAllBuildings(f.state)[0].type == BuildingType::Splitter); @@ -2199,12 +2202,13 @@ namespace // Advances the sim until the given site becomes an operational building, or a // safety cap is reached. - void buildToCompletion(BuildingSystem& bs, const GameConfig& cfg, FactoryState& state, - BeltSystem& belts, BuildingId id, Tick& tick) + void buildToCompletion(ProductionSystem& production, const GameConfig& cfg, + FactoryState& state, BeltSystem& belts, BuildingId id, + Tick& tick) { for (int i = 0; i < 20000 && findBuilding(state, id) == nullptr; ++i) { - runTicks(bs, cfg, state, belts, 1, tick); + runTicks(production, cfg, state, belts, 1, tick); } } } @@ -2239,7 +2243,7 @@ TEST_CASE("BuildingSystem: getInputPorts matches between a site and the built bu const BuildingId id = f.bs.place(f.state, BuildingType::Miner, QPoint(0, 0), Rotation::East, 0).value(); const std::vector sitePorts = getInputPorts(f.state, f.cfg, id); - buildToCompletion(f.bs, f.cfg, f.state, f.belts, id, tick); + buildToCompletion(f.production, f.cfg, f.state, f.belts, id, tick); REQUIRE(findBuilding(f.state, id) != nullptr); const std::vector builtPorts = getInputPorts(f.state, f.cfg, id); diff --git a/src/test/CombatSystemTest.cpp b/src/test/CombatSystemTest.cpp index 27d9e81..ea321db 100644 --- a/src/test/CombatSystemTest.cpp +++ b/src/test/CombatSystemTest.cpp @@ -65,10 +65,7 @@ struct CombatFixture , rng(42) , belts(cfg.world.beltSpeed_tps) , ships(cfg, admin) - , buildings(cfg, belts, - [](const std::string&, QVector2D, const std::optional&) {}, - [](const std::string&) -> bool { return true; }, - rng) + , buildings(cfg, belts) , combat(cfg) { }