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

@@ -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.
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;
// 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;
};

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;