separate what buildings are from what flows through them

BuildingSystem was four unrelated jobs in one class: building lifecycle,
building configuration, the per-tick material flow, and (until last commit) the
checksum. The flow was the odd one out -- it is what the RNG, the ship spawner
and the unlock test were held for, none of which placement, rotation or
demolition has any business reaching.

Tick steps 3 to 5 move to a new ProductionSystem: tickBeltPull, tickProduction,
tickShipyardProduction, tickOutputBelts, their five private helpers and
rollOutputGroup. The cut is clean in both directions -- nothing in the block
called a topology or configuration method, and nothing outside it called the
helpers -- so the bodies move verbatim; a scripted comparison against the old
file confirms all nine differ only by class qualifier, the m_belts -> belts
rename, and the two added parameters. (Seven em dashes in comments became `--`;
the new file is ASCII, as the guidelines require.)

Belts arrive per tick rather than being held, matching ConstructionSystem, and
only the two methods that touch them take the parameter. BuildingSystem is left
holding the config and the belts, and its constructor takes exactly those two.
The arena constructs no ProductionSystem at all: it stages ships directly and
never runs a factory.

Determinism rests on the RNG stream: step 4's weighted output-group pick is the
factory's only draw, so the four calls must keep their order and position in
Simulation::tick. They do, and the tick-order section now says why, since no
test can catch a reordering here.

913 lines of BuildingSystem.cpp become 444 there and 483 in ProductionSystem.cpp.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ne3mejABZoLWKLh8fgpM3x
This commit is contained in:
2026-08-20 07:41:20 +02:00
parent 04a52698e8
commit 46649e7dd1
12 changed files with 708 additions and 634 deletions

View File

@@ -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 35 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 35). 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

View File

@@ -47,12 +47,9 @@ ArenaSimulation::ArenaSimulation(const GameConfig& gameConfig,
{
m_factoryState = makeFactoryState(m_gameConfig);
m_buildingSystem = std::make_unique<BuildingSystem>(
m_gameConfig,
m_beltSystem,
[](const std::string&, QVector2D, const std::optional<ShipLayoutConfig>&) {},
[](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<BuildingSystem>(m_gameConfig, m_beltSystem);
m_shipSystem = std::make_unique<ShipSystem>(m_gameConfig, m_admin);
// Arena fights are symmetric and aggressive: player-faction ships must not

View File

@@ -2,39 +2,17 @@
#include <algorithm>
#include <cassert>
#include <limits>
#include <random>
#include <set>
#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<BeltItemSlot>& lane)
{
return lane.size() < 3 && (lane.empty() || lane.back().progress >= 0.25);
}
} // namespace
BuildingSystem::BuildingSystem(const GameConfig& config,
BeltSystem& belts,
std::function<void(const std::string&, QVector2D,
const std::optional<ShipLayoutConfig>&)> spawnShip,
std::function<bool(const std::string&)> 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<Item> itemsOf(const RecipeOutputGroup& group)
{
std::vector<Item> 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<Item> 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<const RecipeOutputGroup*> eligible;
std::vector<double> 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<int> dist(weights.begin(), weights.end());
return itemsOf(*eligible[static_cast<std::size_t>(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<BeltItemSlot>& lane = building.incomingItems[i];
advanceBeltSlots(lane, progressPerTick);
while (!lane.empty() && lane.front().progress >= 0.5)
{
const Item arrived = lane.front().item;
lane.erase(lane.begin());
if (isHq)
{
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<ItemType> 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<Item> 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<ItemType, int>::const_iterator capIt =
consumer.inputBuffer.caps.find(type);
if (capIt == consumer.inputBuffer.caps.end() || capIt->second == 0)
{
return false;
}
return consumer.pendingInputCount(type) < capIt->second;
}
void BuildingSystem::depositToInputBelt(Building& consumer,
std::size_t inputPortIndex,
const Item& item)
{
consumer.incomingItems[inputPortIndex].push_back(BeltItemSlot{item, 0.0});
}
bool BuildingSystem::tryDirectCoupleDeposit(FactoryState& state, BuildingId producerId,
const Port& outputPort,
const Item& item)
{
const std::optional<BuildingId> 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<Item> 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<ShipLayoutConfig> layout =
building.shipLayout.has_value()
? building.shipLayout
: std::make_optional<ShipLayoutConfig>();
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<std::string, int> requiredMaterials =
computeShipyardRequiredMaterials(m_config, building);
// Idle: check if all combined materials are available.
bool inputsOk = true;
for (const std::pair<const std::string, int>& req : requiredMaterials)
{
const ItemType type{req.first};
const std::map<ItemType, int>::const_iterator it =
building.inputBuffer.counts.find(type);
const int have = (it != building.inputBuffer.counts.end()) ? it->second : 0;
if (have < req.second)
{
inputsOk = false;
break;
}
}
if (!inputsOk)
{
continue;
}
// Consume combined materials and start the production cycle.
for (const std::pair<const std::string, int>& req : requiredMaterials)
{
building.inputBuffer.counts[ItemType{req.first}] -= req.second;
}
double totalTime = shipDef->schematic.productionTimeSeconds;
if (building.shipLayout.has_value())
{
for (const PlacedModule& pm : building.shipLayout->placedModules)
{
const ModuleDef* modDef = 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<BeltItemSlot>& lane = building.emergingItems[p];
// 1. Advance emerging items using the shared belt packing (progress
// caps to 0.5 / 0.75 / 1.0 for up to three items).
advanceBeltSlots(lane, progressPerTick);
// 2. Hand the front item off once it reaches the output edge (progress
// 1.0): onto the adjacent real belt, or — if a building's input edge
// meets this port — straight into that building (REQ-MAT-DIRECT-COUPLE).
// On refusal (no belt/coupling, output-edge per REQ-MAT-ACCEPT-DIR, or
// a full target) it stays stuck at 1.0.
if (!lane.empty() && lane.front().progress >= 1.0)
{
const Item item = lane.front().item;
if (m_belts.tryPutItem(port.tile, item, port.direction)
|| tryDirectCoupleDeposit(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.

View File

@@ -4,13 +4,11 @@
#include <functional>
#include <map>
#include <optional>
#include <random>
#include <string>
#include <utility>
#include <vector>
#include <QPoint>
#include <QPointF>
#include <QVector2D>
#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<void(const std::string&, QVector2D,
const std::optional<ShipLayoutConfig>&)> spawnShip,
std::function<bool(const std::string&)> 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<ItemType>& filterA,
const std::vector<ItemType>& 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<void(Building&)> 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<Item> 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.
// 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;
std::function<void(const std::string&, QVector2D,
const std::optional<ShipLayoutConfig>&)> m_spawnShip;
std::function<bool(const std::string&)> m_isItemUnlocked;
std::mt19937& m_rng;
};

View File

@@ -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

View File

@@ -0,0 +1,483 @@
#include "ProductionSystem.h"
#include <algorithm>
#include <optional>
#include <random>
#include <vector>
#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<void(const std::string&, QVector2D,
const std::optional<ShipLayoutConfig>&)> spawnShip,
std::function<bool(const std::string&)> 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<BeltItemSlot>& 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<Item> itemsOf(const RecipeOutputGroup& group)
{
std::vector<Item> 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<Item> 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<const RecipeOutputGroup*> eligible;
std::vector<double> 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<int> dist(weights.begin(), weights.end());
return itemsOf(*eligible[static_cast<std::size_t>(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<BeltItemSlot>& lane = building.incomingItems[i];
advanceBeltSlots(lane, progressPerTick);
while (!lane.empty() && lane.front().progress >= 0.5)
{
const Item arrived = lane.front().item;
lane.erase(lane.begin());
if (isHq)
{
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<ItemType> 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<Item> 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<ItemType, int>::const_iterator capIt =
consumer.inputBuffer.caps.find(type);
if (capIt == consumer.inputBuffer.caps.end() || capIt->second == 0)
{
return false;
}
return consumer.pendingInputCount(type) < capIt->second;
}
void 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<BuildingId> 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<Item> 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<ShipLayoutConfig> layout =
building.shipLayout.has_value()
? building.shipLayout
: std::make_optional<ShipLayoutConfig>();
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<std::string, int> requiredMaterials =
computeShipyardRequiredMaterials(m_config, building);
// Idle: check if all combined materials are available.
bool inputsOk = true;
for (const std::pair<const std::string, int>& req : requiredMaterials)
{
const ItemType type{req.first};
const std::map<ItemType, int>::const_iterator it =
building.inputBuffer.counts.find(type);
const int have = (it != building.inputBuffer.counts.end()) ? it->second : 0;
if (have < req.second)
{
inputsOk = false;
break;
}
}
if (!inputsOk)
{
continue;
}
// Consume combined materials and start the production cycle.
for (const std::pair<const std::string, int>& req : requiredMaterials)
{
building.inputBuffer.counts[ItemType{req.first}] -= req.second;
}
double totalTime = shipDef->schematic.productionTimeSeconds;
if (building.shipLayout.has_value())
{
for (const PlacedModule& pm : building.shipLayout->placedModules)
{
const ModuleDef* modDef = 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<BeltItemSlot>& lane = building.emergingItems[p];
// 1. Advance emerging items using the shared belt packing (progress
// caps to 0.5 / 0.75 / 1.0 for up to three items).
advanceBeltSlots(lane, progressPerTick);
// 2. Hand the front item off once it reaches the output edge (progress
// 1.0): onto the adjacent real belt, or -- if a building's input edge
// meets this port -- straight into that building (REQ-MAT-DIRECT-COUPLE).
// On refusal (no belt/coupling, output-edge per REQ-MAT-ACCEPT-DIR, or
// a full target) it stays stuck at 1.0.
if (!lane.empty() && lane.front().progress >= 1.0)
{
const Item item = lane.front().item;
if (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());
}
}
}
}

View File

@@ -0,0 +1,105 @@
#pragma once
#include <cstddef>
#include <functional>
#include <optional>
#include <random>
#include <string>
#include <vector>
#include <QVector2D>
#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<void(const std::string&, QVector2D,
const std::optional<ShipLayoutConfig>&)> spawnShip,
std::function<bool(const std::string&)> 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<Item> 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<void(const std::string&, QVector2D,
const std::optional<ShipLayoutConfig>&)> m_spawnShip;
std::function<bool(const std::string&)> 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;
};

View File

@@ -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<BuildingSystem>(
m_buildingSystem = std::make_unique<BuildingSystem>(m_config, m_beltSystem);
m_productionSystem = std::make_unique<ProductionSystem>(
m_config,
m_beltSystem,
[this](const std::string& id, QVector2D pos,
const std::optional<ShipLayoutConfig>& 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)

View File

@@ -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<BuildingSystem> m_buildingSystem;
std::unique_ptr<ProductionSystem> m_productionSystem;
std::unique_ptr<ConstructionSystem> m_constructionSystem;
std::unique_ptr<DeconstructionSystem> m_deconstructionSystem;
std::unique_ptr<ShipSystem> m_shipSystem;

View File

@@ -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<ShipLayoutConfig>&) {},
[](const std::string&) -> bool { return true; },
rng)
, buildings(cfg, belts)
, construction(cfg)
, ships(cfg, admin)
, ai(cfg)

View File

@@ -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;
}
@@ -100,6 +101,7 @@ struct PlacementFixture
BeltSystem belts;
std::mt19937 rng{0};
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<double> beltSpeed_tps = std::nullopt,
std::function<bool(const std::string&)> 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<ShipLayoutConfig>&) {},
isItemUnlocked ? std::move(isItemUnlocked)
: std::function<bool(const std::string&)>(
@@ -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<int>(secondsToTicks(1.0)) + 1, tick);
runTicks(f.production, f.cfg, f.state, f.belts, static_cast<int>(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<int>(secondsToTicks(10.0)) + 1, tick);
runTicks(f.production, f.cfg, f.state, f.belts, static_cast<int>(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<int>(secondsToTicks(0.1)) + 1, tick);
runTicks(f.production, f.cfg, f.state, f.belts, static_cast<int>(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<int>(secondsToTicks(0.1)) + 1, tick);
runTicks(f.production, f.cfg, f.state, f.belts, static_cast<int>(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<int>(secondsToTicks(0.1)) + 2, tick);
runTicks(f.production, f.cfg, f.state, f.belts, static_cast<int>(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<int>(secondsToTicks(0.1)) + 5, tick);
runTicks(f.production, f.cfg, f.state, f.belts, static_cast<int>(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<int>(secondsToTicks(10.0)) + 1, tick);
runTicks(f.production, f.cfg, f.state, f.belts, static_cast<int>(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<int>(secondsToTicks(10.0)) + static_cast<int>(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<int>(secondsToTicks(10.0))
+ 2 * static_cast<int>(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<int>(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<int>(cycleTicks), tick);
runTicks(f.production, f.cfg, f.state, f.belts, static_cast<int>(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<BeltItemSlot>& lane : building.emergingItems) { lane.clear(); }
});
runTicks(f.bs, f.cfg, f.state, f.belts, static_cast<int>(cycleTicks), tick);
runTicks(f.production, f.cfg, f.state, f.belts, static_cast<int>(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<int>(secondsToTicks(10.0)) + 1, tick);
runTicks(f.production, f.cfg, f.state, f.belts, static_cast<int>(secondsToTicks(10.0)) + 1, tick);
REQUIRE(getProductionBuildingCount(f.state) == 1);
runTicks(f.bs, f.cfg, f.state, f.belts, static_cast<int>(secondsToTicks(15.0)), tick);
runTicks(f.production, f.cfg, f.state, f.belts, static_cast<int>(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<int>(secondsToTicks(10.0)) + 1, tick);
runTicks(f.production, f.cfg, f.state, f.belts, static_cast<int>(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<int>(secondsToTicks(1.0)) + 1, tick);
runTicks(f.production, f.cfg, f.state, f.belts, 2 * static_cast<int>(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<int>(secondsToTicks(15.0)) + 1, tick);
runTicks(f.production, f.cfg, f.state, f.belts, static_cast<int>(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<int>(secondsToTicks(15.0)) + 1, tick);
runTicks(f.production, f.cfg, f.state, f.belts, static_cast<int>(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<int>(secondsToTicks(25.0)) + 1, tick);
runTicks(f.production, f.cfg, f.state, f.belts, static_cast<int>(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<int>(secondsToTicks(15.0)) + 1, tick);
runTicks(f.production, f.cfg, f.state, f.belts, static_cast<int>(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<int>(secondsToTicks(2.0)) + 2, tick);
runTicks(f.production, f.cfg, f.state, f.belts, static_cast<int>(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<int>(secondsToTicks(15.0)) + 1, tick);
runTicks(f.production, f.cfg, f.state, f.belts, static_cast<int>(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<int>(secondsToTicks(2.5)) + 2, tick);
runTicks(f.production, f.cfg, f.state, f.belts, static_cast<int>(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<int>(secondsToTicks(10.0)) + static_cast<int>(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> 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<int>(secondsToTicks(30.0)), tick);
runTicks(f.production, f.cfg, f.state, f.belts, static_cast<int>(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<int>(secondsToTicks(25.0)), tick);
runTicks(f.production, f.cfg, f.state, f.belts, static_cast<int>(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<int>(secondsToTicks(10.0)) + static_cast<int>(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<int>(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<int>(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<BeltItemSlot>& 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<int>(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<int>(secondsToTicks(25.0)) + 1, tick);
runTicks(f.production, f.cfg, f.state, f.belts, static_cast<int>(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<int>(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<int>(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<int>(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<int>(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<int>(secondsToTicks(25.0)) + 1, tick);
runTicks(f.production, f.cfg, f.state, f.belts, static_cast<int>(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<int>(secondsToTicks(3.0)) + 1, tick);
runTicks(f.production, f.cfg, f.state, f.belts, static_cast<int>(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<int>(secondsToTicks(1.0)) + 1, tick);
runTicks(f.production, f.cfg, f.state, f.belts, static_cast<int>(secondsToTicks(1.0)) + 1, tick);
REQUIRE(getAllSites(f.state).empty());
const std::optional<BuildingId> 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<int>(secondsToTicks(1.0)) + 1, tick);
runTicks(f.production, f.cfg, f.state, f.belts, static_cast<int>(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<int>(secondsToTicks(1.0)) + 1, tick);
runTicks(f.production, f.cfg, f.state, f.belts, static_cast<int>(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<Port> 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<Port> builtPorts = getInputPorts(f.state, f.cfg, id);

View File

@@ -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<ShipLayoutConfig>&) {},
[](const std::string&) -> bool { return true; },
rng)
, buildings(cfg, belts)
, combat(cfg)
{
}