An auto-recipe building adopted whatever recipe of its type consumed the material offered to it, without asking the unlock state. That was the last way a building could come to run a recipe the player could not have selected: the selection dialog hides those and the blueprint gate discards them, but a belt delivering the right material installed one regardless. Automatic selection now asks isRecipeUnlocked, the same question the dialog asks, plumbed in beside the isItemUnlocked the output pool already uses. Two callbacks, two questions, one unlock state -- rather than a second definition of "a recipe the player may run" written out inside the sim. A material whose only recipes are locked now selects nothing, and an unconfigured building has no input buffer, so that material is refused rather than swallowed: it stays on the belt and the line backs up behind an idle building. That is the intended failure -- a stalled belt is visible, a building quietly eating a material the player cannot use is not. Selection only. A recipe already set goes on producing under REQ-LOCK-OUTPUT-POOL, which deliberately never tests a single-group recipe. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Ne3mejABZoLWKLh8fgpM3x
488 lines
19 KiB
C++
488 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::function<bool(const std::string&)> isRecipeUnlocked,
|
|
std::mt19937& rng)
|
|
: m_config(config)
|
|
, m_spawnShip(std::move(spawnShip))
|
|
, m_isItemUnlocked(std::move(isItemUnlocked))
|
|
, m_isRecipeUnlocked(std::move(isRecipeUnlocked))
|
|
, 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 the unlocked ones can be picked (REQ-LOCK-OUTPUT-POOL), and
|
|
// 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)
|
|
{
|
|
if (!isOutputGroupUnlocked(group, m_isItemUnlocked)) { 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;
|
|
}
|
|
// Only a recipe the player could have selected themselves (REQ-LOCK-UI-RECIPE): a
|
|
// building must not drift into running one that can yield them nothing. Refusing it
|
|
// leaves the building unconfigured and so without an input buffer, which is what
|
|
// keeps the offered item on the belt rather than swallowing it into a building that
|
|
// has no use for it (REQ-MAT-INPUT-INTAKE).
|
|
if (!m_isRecipeUnlocked(recipe->id))
|
|
{
|
|
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());
|
|
}
|
|
}
|
|
}
|
|
}
|