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
484 lines
19 KiB
C++
484 lines
19 KiB
C++
#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());
|
|
}
|
|
}
|
|
}
|
|
}
|