allow the probabilistic recipe output of the reprocessing plant to yield more than 1 item of a type per cycle
This commit is contained in:
@@ -27,11 +27,14 @@ struct InputBuffer
|
||||
std::map<ItemType, int> caps; // max items per material (2× per-cycle requirement)
|
||||
};
|
||||
|
||||
// Output buffer shared by all output materials for a production building.
|
||||
// Per-material output buffer for a production building. The items are held in one
|
||||
// production-ordered queue -- that is the order they leave at the output port
|
||||
// (REQ-MAT-OUTPUT-EMERGE) -- while the capacity is per item type, so one item's backlog
|
||||
// never occupies another's room (REQ-MAT-OUTPUT-BUFFER).
|
||||
struct OutputBuffer
|
||||
{
|
||||
std::vector<Item> items;
|
||||
int capacity = 0; // 2× per-cycle output; 1× for ReprocessingPlant
|
||||
std::vector<Item> items; // production order; feeds the output belt
|
||||
std::map<ItemType, int> caps; // max items per material (2x its per-cycle amount)
|
||||
};
|
||||
|
||||
// Active production cycle for a building.
|
||||
@@ -96,6 +99,25 @@ struct Building
|
||||
return count;
|
||||
}
|
||||
|
||||
// The same over one material, which is what its own capacity is measured against
|
||||
// (REQ-MAT-OUTPUT-BUFFER).
|
||||
int getOutputItemCount(const ItemType& type) const
|
||||
{
|
||||
int count = 0;
|
||||
for (const Item& item : outputBuffer.items)
|
||||
{
|
||||
if (item.type == type) { ++count; }
|
||||
}
|
||||
for (const std::vector<BeltItemSlot>& lane : emergingItems)
|
||||
{
|
||||
for (const BeltItemSlot& slot : lane)
|
||||
{
|
||||
if (slot.item.type == type) { ++count; }
|
||||
}
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
// Items currently travelling inward on each input port's virtual input belt
|
||||
// (REQ-MAT-INPUT-INTAKE); one lane per input port, parallel to inputPorts. Each
|
||||
// lane holds slots at progress [0.0, 0.5], front (highest progress) first. An
|
||||
|
||||
@@ -2,12 +2,48 @@
|
||||
|
||||
#include <algorithm>
|
||||
#include <cassert>
|
||||
#include <map>
|
||||
|
||||
#include "BuildingType.h"
|
||||
#include "ItemType.h"
|
||||
#include "ModulesConfig.h"
|
||||
#include "ShipsConfig.h"
|
||||
|
||||
namespace
|
||||
{
|
||||
|
||||
// Folds the output capacities one recipe implies into `caps`: twice each produced
|
||||
// item's per-cycle amount (REQ-MAT-OUTPUT-BUFFER). A Reprocessing Plant rolls exactly
|
||||
// one of its outputs per cycle (REQ-BLD-REPROCESSING), so its per-cycle amount for an
|
||||
// item is that one outcome's amount rather than a sum over the entries.
|
||||
//
|
||||
// Where a cap is already present the larger wins, which is how an auto-recipe building
|
||||
// unions the recipes of its type -- the same rule its input caps follow.
|
||||
void addOutputCaps(std::map<ItemType, int>& caps, BuildingType type,
|
||||
const RecipeDef& recipe)
|
||||
{
|
||||
std::map<ItemType, int> perCycle;
|
||||
for (const RecipeOutput& out : recipe.outputs)
|
||||
{
|
||||
const ItemType item{out.item};
|
||||
if (type == BuildingType::ReprocessingPlant)
|
||||
{
|
||||
perCycle[item] = std::max(perCycle[item], out.amount);
|
||||
}
|
||||
else
|
||||
{
|
||||
perCycle[item] += out.amount;
|
||||
}
|
||||
}
|
||||
|
||||
for (const std::pair<const ItemType, int>& entry : perCycle)
|
||||
{
|
||||
caps[entry.first] = std::max(caps[entry.first], 2 * entry.second);
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
void initBuffers(Building& b, const RecipeDef& recipe)
|
||||
{
|
||||
b.inputBuffer.counts.clear();
|
||||
@@ -20,29 +56,8 @@ void initBuffers(Building& b, const RecipeDef& recipe)
|
||||
}
|
||||
|
||||
b.outputBuffer.items.clear();
|
||||
if (b.type == BuildingType::ReprocessingPlant)
|
||||
{
|
||||
// 1× max-per-roll (REQ-MAT-OUTPUT-BUFFER-REPROCESSING).
|
||||
int maxAmount = 0;
|
||||
for (const RecipeOutput& out : recipe.outputs)
|
||||
{
|
||||
if (out.amount > maxAmount)
|
||||
{
|
||||
maxAmount = out.amount;
|
||||
}
|
||||
}
|
||||
b.outputBuffer.capacity = maxAmount;
|
||||
}
|
||||
else
|
||||
{
|
||||
// 2× per-cycle output.
|
||||
int totalAmount = 0;
|
||||
for (const RecipeOutput& out : recipe.outputs)
|
||||
{
|
||||
totalAmount += out.amount;
|
||||
}
|
||||
b.outputBuffer.capacity = 2 * totalAmount;
|
||||
}
|
||||
b.outputBuffer.caps.clear();
|
||||
addOutputCaps(b.outputBuffer.caps, b.type, recipe);
|
||||
}
|
||||
|
||||
void initAutoBuffers(const GameConfig& config, Building& b)
|
||||
@@ -50,12 +65,12 @@ void initAutoBuffers(const GameConfig& config, Building& b)
|
||||
b.inputBuffer.counts.clear();
|
||||
b.inputBuffer.caps.clear();
|
||||
|
||||
// Union the inputs of every recipe of this building type; the cap for each
|
||||
// item is twice the largest per-cycle requirement across those recipes.
|
||||
// Output capacity follows the same rules as initBuffers: the Reprocessing
|
||||
// Plant holds one cycle's max output (REQ-MAT-OUTPUT-BUFFER-REPROCESSING),
|
||||
// other auto buildings hold twice the largest per-cycle output.
|
||||
int outputCapacity = 0;
|
||||
b.outputBuffer.items.clear();
|
||||
b.outputBuffer.caps.clear();
|
||||
|
||||
// Union both sides over every recipe of this building type: the cap for each item is
|
||||
// twice the largest per-cycle amount across those recipes, on the input side as on
|
||||
// the output side (REQ-MAT-INPUT-BUFFER, REQ-MAT-OUTPUT-BUFFER).
|
||||
for (const RecipeDef& recipe : config.recipes.recipes)
|
||||
{
|
||||
if (recipe.building != b.type)
|
||||
@@ -71,36 +86,18 @@ void initAutoBuffers(const GameConfig& config, Building& b)
|
||||
std::max(b.inputBuffer.caps[type], 2 * ing.amount);
|
||||
}
|
||||
|
||||
if (b.type == BuildingType::ReprocessingPlant)
|
||||
{
|
||||
int maxAmount = 0;
|
||||
for (const RecipeOutput& out : recipe.outputs)
|
||||
{
|
||||
maxAmount = std::max(maxAmount, out.amount);
|
||||
}
|
||||
outputCapacity = std::max(outputCapacity, maxAmount);
|
||||
}
|
||||
else
|
||||
{
|
||||
int totalAmount = 0;
|
||||
for (const RecipeOutput& out : recipe.outputs)
|
||||
{
|
||||
totalAmount += out.amount;
|
||||
}
|
||||
outputCapacity = std::max(outputCapacity, 2 * totalAmount);
|
||||
}
|
||||
addOutputCaps(b.outputBuffer.caps, b.type, recipe);
|
||||
}
|
||||
|
||||
b.outputBuffer.items.clear();
|
||||
b.outputBuffer.capacity = outputCapacity;
|
||||
}
|
||||
|
||||
void initShipyardBuffers(const GameConfig& config, Building& b)
|
||||
{
|
||||
b.inputBuffer.counts.clear();
|
||||
b.inputBuffer.caps.clear();
|
||||
// A shipyard spawns a ship rather than producing items, so it holds no output
|
||||
// buffer at all (REQ-MAT-OUTPUT-BUFFER, REQ-BLD-SHIPYARD).
|
||||
b.outputBuffer.items.clear();
|
||||
b.outputBuffer.capacity = 0;
|
||||
b.outputBuffer.caps.clear();
|
||||
const ShipDef* def = config.ships.findShipDef(b.recipeId);
|
||||
if (!def)
|
||||
{
|
||||
@@ -133,11 +130,13 @@ void initShipyardBuffers(const GameConfig& config, Building& b)
|
||||
|
||||
void initSalvageBayBuffer(const GameConfig& config, Building& b)
|
||||
{
|
||||
// Salvage Bay has no recipe-driven buffer; its output-buffer holding size for
|
||||
// ship drop-off is config-defined (REQ-BLD-SALVAGE-BAY).
|
||||
// Salvage Bay has no recipe-driven buffer; scrap is the only thing it ever holds,
|
||||
// and that single buffer's holding size for ship drop-off is config-defined
|
||||
// (REQ-BLD-SALVAGE-BAY).
|
||||
b.outputBuffer.items.clear();
|
||||
b.outputBuffer.caps.clear();
|
||||
const BuildingDef* def = config.buildings.findBuildingDef(BuildingType::SalvageBay);
|
||||
b.outputBuffer.capacity =
|
||||
b.outputBuffer.caps[ItemType{"scrap"}] =
|
||||
(def && def->outputBufferCapacity) ? *def->outputBufferCapacity : 0;
|
||||
}
|
||||
|
||||
|
||||
@@ -13,9 +13,9 @@
|
||||
// to BeltSystem. Free functions over the config and the building — they read no
|
||||
// factory state, so both BuildingSystem and ConstructionSystem can use them.
|
||||
|
||||
// Buffers for a building running one known recipe: inputs capped at twice each
|
||||
// ingredient's per-cycle amount, output at twice the per-cycle total (one cycle's
|
||||
// max for a Reprocessing Plant, REQ-MAT-OUTPUT-BUFFER-REPROCESSING).
|
||||
// Buffers for a building running one known recipe: one buffer per material on each
|
||||
// side, capped at twice that material's per-cycle amount (REQ-MAT-INPUT-BUFFER,
|
||||
// REQ-MAT-OUTPUT-BUFFER).
|
||||
void initBuffers(Building& b, const RecipeDef& recipe);
|
||||
|
||||
// Buffers for an auto-recipe building (Smelter, Reprocessing Plant), unioned over
|
||||
|
||||
@@ -240,7 +240,7 @@ void BuildingSystem::setRecipe(FactoryState& state, BuildingId id, const std::st
|
||||
building.inputBuffer.counts.clear();
|
||||
building.inputBuffer.caps.clear();
|
||||
building.outputBuffer.items.clear();
|
||||
building.outputBuffer.capacity = 0;
|
||||
building.outputBuffer.caps.clear();
|
||||
// Emerging items are part of the output buffer, so clearing it on a
|
||||
// recipe change discards them too (REQ-MAT-OUTPUT-EMERGE); in-transit
|
||||
// input items are discarded and their reservations released
|
||||
@@ -307,7 +307,7 @@ void BuildingSystem::setShipLayout(FactoryState& state, BuildingId id, const Shi
|
||||
building.inputBuffer.counts.clear();
|
||||
building.inputBuffer.caps.clear();
|
||||
building.outputBuffer.items.clear();
|
||||
building.outputBuffer.capacity = 0;
|
||||
building.outputBuffer.caps.clear();
|
||||
for (std::vector<BeltItemSlot>& lane : building.emergingItems) { lane.clear(); }
|
||||
for (std::vector<BeltItemSlot>& lane : building.incomingItems) { lane.clear(); }
|
||||
if (!building.recipeId.empty() && building.type == BuildingType::Shipyard)
|
||||
@@ -547,7 +547,20 @@ void BuildingSystem::tickProduction(FactoryState& state, Tick currentTick)
|
||||
continue;
|
||||
}
|
||||
|
||||
// 2. Determine chosen outputs (roll for reprocessing).
|
||||
// 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. Determine chosen outputs (roll for reprocessing).
|
||||
std::vector<Item> chosen;
|
||||
if (building.type == BuildingType::ReprocessingPlant)
|
||||
{
|
||||
@@ -567,15 +580,6 @@ void BuildingSystem::tickProduction(FactoryState& state, Tick currentTick)
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Output buffer has space for chosen outputs? Emerging items still
|
||||
// count against the 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 (!outputBufferHasRoom(building, static_cast<int>(chosen.size())))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
// 4. Consume inputs and start cycle.
|
||||
for (const RecipeIngredient& ing : recipe->inputs)
|
||||
{
|
||||
@@ -950,21 +954,22 @@ void appendItems(Hasher& hasher, const std::vector<Item>& items)
|
||||
}
|
||||
}
|
||||
|
||||
// std::map<ItemType, int> iterates in sorted-id order (ItemType::operator<), so both
|
||||
// buffer sides hash the same way in every run.
|
||||
void appendItemCounts(Hasher& hasher, const std::map<ItemType, int>& counts)
|
||||
{
|
||||
hasher.append(counts.size());
|
||||
for (const std::pair<const ItemType, int>& entry : counts)
|
||||
{
|
||||
hasher.append(entry.first.id);
|
||||
hasher.append(entry.second);
|
||||
}
|
||||
}
|
||||
|
||||
void appendInputBuffer(Hasher& hasher, const InputBuffer& buffer)
|
||||
{
|
||||
// std::map<ItemType, int> iterates in sorted-id order (ItemType::operator<).
|
||||
hasher.append(buffer.counts.size());
|
||||
for (const std::pair<const ItemType, int>& entry : buffer.counts)
|
||||
{
|
||||
hasher.append(entry.first.id);
|
||||
hasher.append(entry.second);
|
||||
}
|
||||
hasher.append(buffer.caps.size());
|
||||
for (const std::pair<const ItemType, int>& entry : buffer.caps)
|
||||
{
|
||||
hasher.append(entry.first.id);
|
||||
hasher.append(entry.second);
|
||||
}
|
||||
appendItemCounts(hasher, buffer.counts);
|
||||
appendItemCounts(hasher, buffer.caps);
|
||||
}
|
||||
} // namespace
|
||||
|
||||
@@ -984,7 +989,7 @@ void BuildingSystem::appendChecksum(const FactoryState& state, Hasher& hasher) c
|
||||
hasher.append(b.recipeId);
|
||||
appendInputBuffer(hasher, b.inputBuffer);
|
||||
appendItems(hasher, b.outputBuffer.items);
|
||||
hasher.append(b.outputBuffer.capacity);
|
||||
appendItemCounts(hasher, b.outputBuffer.caps);
|
||||
hasher.append(b.emergingItems.size());
|
||||
for (const std::vector<BeltItemSlot>& lane : b.emergingItems)
|
||||
{
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
#include <limits>
|
||||
|
||||
#include "PortGeometry.h"
|
||||
#include "ProductionRules.h"
|
||||
#include "SurfaceMask.h"
|
||||
|
||||
#include "Item.h"
|
||||
@@ -122,12 +123,14 @@ bool deliverScrapToSalvageBay(FactoryState& state, BuildingId bayId)
|
||||
return false; // queued for deconstruction: stopped operating (REQ-BLD-DECON-QUEUE)
|
||||
}
|
||||
// Emerging scrap still counts against the bay's holding capacity
|
||||
// (REQ-MAT-OUTPUT-EMERGE).
|
||||
if (bay->getOutputItemCount() >= bay->outputBuffer.capacity)
|
||||
// (REQ-MAT-OUTPUT-EMERGE). Scrap is all the bay ever holds, so its single buffer is
|
||||
// the one being filled (REQ-BLD-SALVAGE-BAY).
|
||||
const ItemType scrap{"scrap"};
|
||||
if (!outputBufferHasRoom(*bay, scrap, 1))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
bay->outputBuffer.items.push_back(Item{ItemType{"scrap"}});
|
||||
bay->outputBuffer.items.push_back(Item{scrap});
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,47 +1,13 @@
|
||||
#include "ProductionRules.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <limits>
|
||||
#include <map>
|
||||
|
||||
#include "BuildingType.h"
|
||||
#include "ItemType.h"
|
||||
#include "ModulesConfig.h"
|
||||
#include "ShipsConfig.h"
|
||||
|
||||
namespace
|
||||
{
|
||||
|
||||
// Items one cycle of this recipe would deposit into the output buffer. A Reprocessing
|
||||
// Plant rolls exactly one of its outputs per cycle (REQ-BLD-REPROCESSING) and the roll
|
||||
// happens in the simulation, so the smallest amount any roll could yield is what decides
|
||||
// whether a cycle could start at all; a larger roll may still not fit.
|
||||
int getCycleOutputItemCount(const Building& b, const RecipeDef& recipe)
|
||||
{
|
||||
if (recipe.outputs.empty())
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (b.type == BuildingType::ReprocessingPlant)
|
||||
{
|
||||
int smallest = std::numeric_limits<int>::max();
|
||||
for (const RecipeOutput& out : recipe.outputs)
|
||||
{
|
||||
smallest = std::min(smallest, out.amount);
|
||||
}
|
||||
return smallest;
|
||||
}
|
||||
|
||||
int total = 0;
|
||||
for (const RecipeOutput& out : recipe.outputs)
|
||||
{
|
||||
total += out.amount;
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
std::vector<const RecipeDef*>
|
||||
gatherCandidateRecipes(const GameConfig& config, const Building& b)
|
||||
{
|
||||
@@ -177,9 +143,44 @@ bool hasInputsToStart(const GameConfig& config, const Building& b)
|
||||
return false;
|
||||
}
|
||||
|
||||
bool outputBufferHasRoom(const Building& b, int outputItemCount)
|
||||
bool outputBufferHasRoom(const Building& b, const ItemType& type, int itemCount)
|
||||
{
|
||||
return b.getOutputItemCount() + outputItemCount <= b.outputBuffer.capacity;
|
||||
const std::map<ItemType, int>::const_iterator capIt = b.outputBuffer.caps.find(type);
|
||||
const int cap = (capIt != b.outputBuffer.caps.end()) ? capIt->second : 0;
|
||||
return b.getOutputItemCount(type) + itemCount <= cap;
|
||||
}
|
||||
|
||||
bool recipeOutputsFit(const Building& b, const RecipeDef& recipe)
|
||||
{
|
||||
if (b.type == BuildingType::ReprocessingPlant)
|
||||
{
|
||||
// One roll yields one of these, so each is measured on its own -- but all of them
|
||||
// have to fit, since which one it will be is not known yet.
|
||||
for (const RecipeOutput& out : recipe.outputs)
|
||||
{
|
||||
if (!outputBufferHasRoom(b, ItemType{out.item}, out.amount))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// A deterministic cycle deposits all of its outputs together. An item listed more
|
||||
// than once is produced in the sum of those amounts, so it is judged once, as a sum.
|
||||
std::map<ItemType, int> perCycle;
|
||||
for (const RecipeOutput& out : recipe.outputs)
|
||||
{
|
||||
perCycle[ItemType{out.item}] += out.amount;
|
||||
}
|
||||
for (const std::pair<const ItemType, int>& entry : perCycle)
|
||||
{
|
||||
if (!outputBufferHasRoom(b, entry.first, entry.second))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
bool canStartCycle(const GameConfig& config, const Building& b)
|
||||
@@ -193,8 +194,7 @@ bool canStartCycle(const GameConfig& config, const Building& b)
|
||||
|
||||
for (const RecipeDef* recipe : gatherCandidateRecipes(config, b))
|
||||
{
|
||||
if (recipeInputsAvailable(b, *recipe)
|
||||
&& outputBufferHasRoom(b, getCycleOutputItemCount(b, *recipe)))
|
||||
if (recipeInputsAvailable(b, *recipe) && recipeOutputsFit(b, *recipe))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -53,13 +53,21 @@ double computeShipyardProductionTimeSeconds(
|
||||
// True when a production cycle could start right now, ignoring output-buffer space.
|
||||
bool hasInputsToStart(const GameConfig& config, const Building& b);
|
||||
|
||||
// True when the building's output side can take `outputItemCount` more items beside
|
||||
// what it already holds. An emerging item has not left the building yet and so still
|
||||
// counts against the capacity (REQ-MAT-OUTPUT-EMERGE, REQ-MAT-OUTPUT-BUFFER).
|
||||
bool outputBufferHasRoom(const Building& b, int outputItemCount);
|
||||
// True when the building can take `itemCount` more items of `type` beside what it
|
||||
// already holds of it. An emerging item has not left the building yet and so still
|
||||
// counts against that material's capacity (REQ-MAT-OUTPUT-EMERGE, REQ-MAT-OUTPUT-BUFFER).
|
||||
bool outputBufferHasRoom(const Building& b, const ItemType& type, int itemCount);
|
||||
|
||||
// True when every output a cycle of this recipe could produce would fit -- the gate a
|
||||
// cycle has to pass before it may start (REQ-MAT-CYCLE). For a deterministic recipe that
|
||||
// is its own outputs. A Reprocessing Plant rolls one of its outputs per cycle
|
||||
// (REQ-BLD-REPROCESSING), so each possibility is judged on its own and all must fit: the
|
||||
// roll is committed the moment the cycle starts, and testing every outcome rather than
|
||||
// the rolled one is what keeps a stalled output belt from biasing the distribution.
|
||||
bool recipeOutputsFit(const Building& b, const RecipeDef& recipe);
|
||||
|
||||
// True when a production cycle could actually start right now: some candidate recipe
|
||||
// has its inputs *and* its output fits (REQ-MAT-CYCLE). Stricter than
|
||||
// has its inputs *and* passes recipeOutputsFit (REQ-MAT-CYCLE). Stricter than
|
||||
// hasInputsToStart, which looks at the input buffers alone.
|
||||
bool canStartCycle(const GameConfig& config, const Building& b);
|
||||
|
||||
|
||||
@@ -1000,8 +1000,9 @@ TEST_CASE("SalvagerSystem: full-cargo ship at its SalvageBay hands over cargo",
|
||||
}
|
||||
const Building* bay = findBuilding(f.state, bayId);
|
||||
REQUIRE(bay != nullptr);
|
||||
// Config-driven output-buffer capacity is applied on placement (REQ-BLD-SALVAGE-BAY).
|
||||
REQUIRE(bay->outputBuffer.capacity == 20);
|
||||
// Config-driven output-buffer capacity is applied on placement, onto the single
|
||||
// scrap buffer the bay holds (REQ-BLD-SALVAGE-BAY).
|
||||
REQUIRE(bay->outputBuffer.caps.at(ItemType{"scrap"}) == 20);
|
||||
|
||||
const QVector2D bayCenter(bay->anchor.x() + bay->footprint.width() / 2.0f,
|
||||
bay->anchor.y() + bay->footprint.height() / 2.0f);
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
|
||||
#include "BeltSystem.h"
|
||||
#include "Building.h"
|
||||
#include "BuildingBuffers.h"
|
||||
#include "BuildingSystem.h"
|
||||
#include "ConstructionSystem.h"
|
||||
#include "DeconstructionSystem.h"
|
||||
@@ -876,7 +877,8 @@ TEST_CASE("BuildingSystem: direct coupling to a non-consumer leaves the item stu
|
||||
REQUIRE(sink != nullptr);
|
||||
// Nothing was delivered, and the producer's output side has backed up to its cap.
|
||||
REQUIRE(sink->pendingInputCount(ItemType{"iron_ore"}) == 0);
|
||||
REQUIRE(miner->getOutputItemCount() == miner->outputBuffer.capacity);
|
||||
REQUIRE(miner->getOutputItemCount(ItemType{"iron_ore"})
|
||||
== miner->outputBuffer.caps.at(ItemType{"iron_ore"}));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -913,10 +915,10 @@ TEST_CASE("BuildingSystem: setRecipe clears output buffer and active production"
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Reprocessing plant — output buffer capacity (REQ-MAT-OUTPUT-BUFFER-REPROCESSING)
|
||||
// Reprocessing plant -- per-item output buffers (REQ-MAT-OUTPUT-BUFFER)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
TEST_CASE("BuildingSystem: reprocessing plant output buffer capacity equals max output per roll",
|
||||
TEST_CASE("BuildingSystem: reprocessing plant sizes one output buffer per possible roll",
|
||||
"[building]")
|
||||
{
|
||||
PlacementFixture f;
|
||||
@@ -933,8 +935,124 @@ TEST_CASE("BuildingSystem: reprocessing plant output buffer capacity equals max
|
||||
const Building* b = findBuilding(f.state, id);
|
||||
REQUIRE(b != nullptr);
|
||||
// reprocessing_cycle outputs: 2 iron_ingot (60%), 1 circuit_board (30%),
|
||||
// 1 advanced_alloy (10%). Max per roll = 2. Capacity = 2 (1× max).
|
||||
REQUIRE(b->outputBuffer.capacity == 2);
|
||||
// 1 advanced_alloy (10%). One roll yields one of them, so each buffer holds twice
|
||||
// that outcome's own amount (REQ-MAT-OUTPUT-BUFFER).
|
||||
REQUIRE(b->outputBuffer.caps.size() == 3);
|
||||
REQUIRE(b->outputBuffer.caps.at(ItemType{"iron_ingot"}) == 4);
|
||||
REQUIRE(b->outputBuffer.caps.at(ItemType{"circuit_board"}) == 2);
|
||||
REQUIRE(b->outputBuffer.caps.at(ItemType{"advanced_alloy"}) == 2);
|
||||
}
|
||||
|
||||
TEST_CASE("BuildingSystem: one full output buffer stops the plant even when the others have room",
|
||||
"[building]")
|
||||
{
|
||||
// The gate that replaced the old one-item cap: a cycle may only start when *every*
|
||||
// outcome would fit, because the roll is committed once it starts (REQ-MAT-CYCLE).
|
||||
// Were the plant to roll first and skip a result that does not fit, a player could
|
||||
// stall one output belt to filter the distribution towards the other items.
|
||||
PlacementFixture f(kFastBeltSpeed_tps);
|
||||
|
||||
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, f.stock,
|
||||
static_cast<int>(secondsToTicks(25.0)) + 1, tick);
|
||||
|
||||
// Feed a full cycle's scrap (5) so only the output side can hold it back.
|
||||
f.belts.placeBelt(QPoint(-1, 0), Rotation::East);
|
||||
for (int i = 0; i < 5; ++i)
|
||||
{
|
||||
f.belts.tryPutItem(QPoint(-1, 0), makeItem("scrap"), Rotation::East);
|
||||
f.belts.tick();
|
||||
f.bs.tickBeltPull(f.state);
|
||||
}
|
||||
|
||||
// Fill the iron_ingot buffer to its cap and leave the other two empty.
|
||||
f.bs.forEachBuilding(f.state, [](Building& building) {
|
||||
if (building.type != BuildingType::ReprocessingPlant) { return; }
|
||||
const int cap = building.outputBuffer.caps.at(ItemType{"iron_ingot"});
|
||||
for (int i = 0; i < cap; ++i)
|
||||
{
|
||||
building.outputBuffer.items.push_back(makeItem("iron_ingot"));
|
||||
}
|
||||
});
|
||||
|
||||
runTicks(f.bs, f.cfg, f.state, f.belts, f.stock, 5, tick);
|
||||
|
||||
const Building* b = findBuilding(f.state, id);
|
||||
REQUIRE(b != nullptr);
|
||||
// circuit_board and advanced_alloy have room, but iron_ingot does not, so no cycle
|
||||
// starts at all and the scrap is still waiting.
|
||||
REQUIRE(b->outputBuffer.caps.at(ItemType{"circuit_board"}) > 0);
|
||||
REQUIRE(outputBufferHasRoom(*b, ItemType{"circuit_board"}, 1));
|
||||
REQUIRE_FALSE(outputBufferHasRoom(*b, ItemType{"iron_ingot"}, 1));
|
||||
REQUIRE_FALSE(b->production.has_value());
|
||||
REQUIRE(b->pendingInputCount(ItemType{"scrap"}) == 5);
|
||||
REQUIRE(getProductionStatus(f.cfg, *b) == ProductionStatus::Blocked);
|
||||
}
|
||||
|
||||
TEST_CASE("BuildingSystem: reprocessing plant runs a second cycle while holding the first output",
|
||||
"[building]")
|
||||
{
|
||||
// Its buffers hold twice each outcome's amount (REQ-MAT-OUTPUT-BUFFER), so a held
|
||||
// result no longer stops the next cycle. The old one-item cap made this impossible:
|
||||
// whatever the first roll was, the plant stalled until that item left the building.
|
||||
PlacementFixture f(kFastBeltSpeed_tps);
|
||||
|
||||
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, f.stock,
|
||||
static_cast<int>(secondsToTicks(25.0)) + 1, tick);
|
||||
|
||||
// Two cycles' worth of scrap (5 each), which is exactly the input cap.
|
||||
f.belts.placeBelt(QPoint(-1, 0), Rotation::East);
|
||||
for (int i = 0; i < 10; ++i)
|
||||
{
|
||||
f.belts.tryPutItem(QPoint(-1, 0), makeItem("scrap"), Rotation::East);
|
||||
f.belts.tick();
|
||||
f.bs.tickBeltPull(f.state);
|
||||
}
|
||||
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, f.stock,
|
||||
static_cast<int>(secondsToTicks(3.0)) + 1, tick);
|
||||
|
||||
const Building* b = findBuilding(f.state, id);
|
||||
REQUIRE(b != nullptr);
|
||||
REQUIRE(b->getOutputItemCount() > 0);
|
||||
// Whichever outcome was rolled, every outcome still fits, so the second cycle is
|
||||
// already running rather than the plant sitting blocked.
|
||||
REQUIRE(b->production.has_value());
|
||||
REQUIRE(getProductionStatus(f.cfg, *b) == ProductionStatus::Producing);
|
||||
}
|
||||
|
||||
TEST_CASE("BuildingSystem: one item's backlog does not block another item's cycle",
|
||||
"[building]")
|
||||
{
|
||||
// Per-item buffers, so a smelter holding iron ingots can still smelt copper
|
||||
// (REQ-MAT-OUTPUT-BUFFER). Under one shared capacity the iron would have blocked it.
|
||||
PlacementFixture f;
|
||||
|
||||
Building smelter;
|
||||
smelter.type = BuildingType::Smelter;
|
||||
initAutoBuffers(f.cfg, smelter);
|
||||
|
||||
// Copper ore in, and the iron_ingot buffer filled to its cap.
|
||||
smelter.inputBuffer.counts[ItemType{"copper_ore"}] =
|
||||
smelter.inputBuffer.caps.at(ItemType{"copper_ore"});
|
||||
const int ironCap = smelter.outputBuffer.caps.at(ItemType{"iron_ingot"});
|
||||
for (int i = 0; i < ironCap; ++i)
|
||||
{
|
||||
smelter.outputBuffer.items.push_back(makeItem("iron_ingot"));
|
||||
}
|
||||
|
||||
REQUIRE_FALSE(outputBufferHasRoom(smelter, ItemType{"iron_ingot"}, 1));
|
||||
REQUIRE(outputBufferHasRoom(smelter, ItemType{"copper_ingot"}, 1));
|
||||
REQUIRE(canStartCycle(f.cfg, smelter));
|
||||
REQUIRE(getProductionStatus(f.cfg, smelter) == ProductionStatus::Producing);
|
||||
}
|
||||
|
||||
TEST_CASE("BuildingSystem: reprocessing plant produces one cycle output then stalls",
|
||||
@@ -1561,8 +1679,8 @@ TEST_CASE("BuildingSystem: getProductionStatus classifies production state", "[b
|
||||
// A miner has no inputs, so its only idle reason is an output buffer with no
|
||||
// room for the next cycle's output.
|
||||
miner.production = std::nullopt;
|
||||
miner.outputBuffer.capacity = 2;
|
||||
miner.outputBuffer.items = { makeItem("iron_ore"), makeItem("iron_ore") };
|
||||
miner.outputBuffer.caps[ItemType{"iron_ore"}] = 2;
|
||||
miner.outputBuffer.items = { makeItem("iron_ore"), makeItem("iron_ore") };
|
||||
REQUIRE(statusOf(miner) == ProductionStatus::Blocked); // -> yellow
|
||||
|
||||
// One item handed off: the next cycle fits again, so the idle tick between two
|
||||
@@ -1570,26 +1688,35 @@ TEST_CASE("BuildingSystem: getProductionStatus classifies production state", "[b
|
||||
miner.outputBuffer.items.pop_back();
|
||||
REQUIRE(statusOf(miner) == ProductionStatus::Producing); // -> green
|
||||
|
||||
// An emerging item has not left the building, so it fills the freed slot and
|
||||
// An emerging item has not left the building, so it fills the freed room and
|
||||
// blocks the cycle again (REQ-MAT-OUTPUT-EMERGE).
|
||||
miner.emergingItems.push_back({ BeltItemSlot{ makeItem("iron_ore"), 0.5 } });
|
||||
REQUIRE(miner.getOutputItemCount() == 2);
|
||||
REQUIRE(miner.getOutputItemCount(ItemType{"iron_ore"}) == 2);
|
||||
REQUIRE(statusOf(miner) == ProductionStatus::Blocked); // -> yellow
|
||||
|
||||
// Another item's backlog is measured against its own buffer, so it changes
|
||||
// nothing here (REQ-MAT-OUTPUT-BUFFER).
|
||||
miner.outputBuffer.caps[ItemType{"copper_ore"}] = 2;
|
||||
miner.outputBuffer.items.push_back(makeItem("copper_ore"));
|
||||
REQUIRE(statusOf(miner) == ProductionStatus::Blocked);
|
||||
miner.outputBuffer.items.clear();
|
||||
REQUIRE(statusOf(miner) == ProductionStatus::Producing);
|
||||
}
|
||||
|
||||
SECTION("Assembler: starved, the transient between cycles, then blocked")
|
||||
{
|
||||
Building assembler; assembler.type = BuildingType::Assembler;
|
||||
assembler.recipeId = assemblerRecipe->id;
|
||||
// Sized the way the simulation sizes it (REQ-MAT-OUTPUT-BUFFER).
|
||||
initBuffers(assembler, *assemblerRecipe);
|
||||
|
||||
const std::string outputItemId = assemblerRecipe->outputs.front().item;
|
||||
int cycleOutput = 0;
|
||||
for (const RecipeOutput& out : assemblerRecipe->outputs)
|
||||
{
|
||||
cycleOutput += out.amount;
|
||||
}
|
||||
REQUIRE(cycleOutput > 0);
|
||||
// The buffer the simulation would give it (REQ-MAT-OUTPUT-BUFFER).
|
||||
assembler.outputBuffer.capacity = 2 * cycleOutput;
|
||||
|
||||
// Idle with inputs missing -> red.
|
||||
REQUIRE(statusOf(assembler) == ProductionStatus::Starved);
|
||||
@@ -1602,11 +1729,11 @@ TEST_CASE("BuildingSystem: getProductionStatus classifies production state", "[b
|
||||
}
|
||||
REQUIRE(statusOf(assembler) == ProductionStatus::Producing);
|
||||
|
||||
// Filled to within less than one cycle's output of capacity: no cycle can start
|
||||
// -> yellow.
|
||||
// That item's own buffer filled to within less than one cycle's output of its
|
||||
// capacity: no cycle can start -> yellow.
|
||||
for (int i = 0; i < cycleOutput + 1; ++i)
|
||||
{
|
||||
assembler.outputBuffer.items.push_back(makeItem("x"));
|
||||
assembler.outputBuffer.items.push_back(makeItem(outputItemId));
|
||||
}
|
||||
REQUIRE(statusOf(assembler) == ProductionStatus::Blocked);
|
||||
|
||||
@@ -1634,10 +1761,11 @@ TEST_CASE("BuildingSystem: getProductionStatus classifies production state", "[b
|
||||
{
|
||||
cycleOutput += out.amount;
|
||||
}
|
||||
const std::string outputItemId = multiOutputRecipe->outputs.front().item;
|
||||
|
||||
Building assembler; assembler.type = BuildingType::Assembler;
|
||||
assembler.recipeId = multiOutputRecipe->id;
|
||||
assembler.outputBuffer.capacity = 2 * cycleOutput;
|
||||
assembler.recipeId = multiOutputRecipe->id;
|
||||
initBuffers(assembler, *multiOutputRecipe);
|
||||
for (const RecipeIngredient& ing : multiOutputRecipe->inputs)
|
||||
{
|
||||
assembler.inputBuffer.counts[ItemType{ing.item}] = ing.amount;
|
||||
@@ -1646,9 +1774,10 @@ TEST_CASE("BuildingSystem: getProductionStatus classifies production state", "[b
|
||||
// One item short of a full cycle's worth of free space.
|
||||
for (int i = 0; i < cycleOutput + 1; ++i)
|
||||
{
|
||||
assembler.outputBuffer.items.push_back(makeItem("x"));
|
||||
assembler.outputBuffer.items.push_back(makeItem(outputItemId));
|
||||
}
|
||||
REQUIRE(assembler.getOutputItemCount() < assembler.outputBuffer.capacity);
|
||||
REQUIRE(assembler.getOutputItemCount(ItemType{outputItemId})
|
||||
< assembler.outputBuffer.caps.at(ItemType{outputItemId}));
|
||||
REQUIRE(statusOf(assembler) == ProductionStatus::Blocked);
|
||||
|
||||
// Exactly one cycle's worth of free space: the cycle fits again.
|
||||
@@ -1656,11 +1785,11 @@ TEST_CASE("BuildingSystem: getProductionStatus classifies production state", "[b
|
||||
REQUIRE(statusOf(assembler) == ProductionStatus::Producing);
|
||||
}
|
||||
|
||||
SECTION("Reprocessing Plant: judged by the smallest output a roll could yield")
|
||||
SECTION("Reprocessing Plant: blocked once any possible roll has no room")
|
||||
{
|
||||
// The plant rolls one of its outputs per cycle (REQ-BLD-REPROCESSING) and the
|
||||
// roll belongs to the simulation, so the status can only say whether *some*
|
||||
// roll could start: it is blocked once not even the smallest output fits.
|
||||
// roll is committed at cycle start, so every outcome has to fit before it may
|
||||
// begin: one full buffer blocks it whatever room the others have (REQ-MAT-CYCLE).
|
||||
const RecipeDef* reprocessingRecipe = nullptr;
|
||||
for (const RecipeDef& r : f.cfg.recipes.recipes)
|
||||
{
|
||||
@@ -1671,50 +1800,31 @@ TEST_CASE("BuildingSystem: getProductionStatus classifies production state", "[b
|
||||
}
|
||||
}
|
||||
REQUIRE(reprocessingRecipe != nullptr);
|
||||
|
||||
int smallestOutput = 0;
|
||||
int largestOutput = 0;
|
||||
for (const RecipeOutput& out : reprocessingRecipe->outputs)
|
||||
{
|
||||
if (smallestOutput == 0 || out.amount < smallestOutput)
|
||||
{
|
||||
smallestOutput = out.amount;
|
||||
}
|
||||
if (out.amount > largestOutput) { largestOutput = out.amount; }
|
||||
}
|
||||
REQUIRE(smallestOutput > 0);
|
||||
REQUIRE(reprocessingRecipe->outputs.size() >= 2);
|
||||
|
||||
Building plant; plant.type = BuildingType::ReprocessingPlant;
|
||||
// One cycle's largest output, as initAutoBuffers sizes it
|
||||
// (REQ-MAT-OUTPUT-BUFFER-REPROCESSING).
|
||||
plant.outputBuffer.capacity = largestOutput;
|
||||
initBuffers(plant, *reprocessingRecipe);
|
||||
for (const RecipeIngredient& ing : reprocessingRecipe->inputs)
|
||||
{
|
||||
plant.inputBuffer.counts[ItemType{ing.item}] = ing.amount;
|
||||
}
|
||||
|
||||
// Empty buffer: a roll fits -> green.
|
||||
// Every buffer empty: whatever the roll turns out to be, it fits -> green.
|
||||
REQUIRE(statusOf(plant) == ProductionStatus::Producing);
|
||||
|
||||
// Room for the smallest output but not for the largest: some roll can still
|
||||
// start, so the plant is waiting on the roll rather than blocked.
|
||||
if (smallestOutput < largestOutput)
|
||||
// Fill one outcome's buffer and leave the rest untouched -> yellow, even though
|
||||
// the other outcomes still have room.
|
||||
const std::string firstItemId = reprocessingRecipe->outputs.front().item;
|
||||
const std::string lastItemId = reprocessingRecipe->outputs.back().item;
|
||||
for (int i = 0; i < plant.outputBuffer.caps.at(ItemType{firstItemId}); ++i)
|
||||
{
|
||||
plant.outputBuffer.items.push_back(makeItem("iron_ingot"));
|
||||
REQUIRE(plant.getOutputItemCount() + largestOutput
|
||||
> plant.outputBuffer.capacity);
|
||||
REQUIRE(statusOf(plant) == ProductionStatus::Producing);
|
||||
plant.outputBuffer.items.pop_back();
|
||||
}
|
||||
|
||||
// Filled so that not even the smallest output fits -> yellow.
|
||||
for (int i = 0; i < largestOutput - smallestOutput + 1; ++i)
|
||||
{
|
||||
plant.outputBuffer.items.push_back(makeItem("iron_ingot"));
|
||||
plant.outputBuffer.items.push_back(makeItem(firstItemId));
|
||||
}
|
||||
REQUIRE(outputBufferHasRoom(plant, ItemType{lastItemId}, 1));
|
||||
REQUIRE_FALSE(outputBufferHasRoom(plant, ItemType{firstItemId}, 1));
|
||||
REQUIRE(statusOf(plant) == ProductionStatus::Blocked);
|
||||
|
||||
// Without the scrap it is starved regardless of the buffer.
|
||||
// Without the scrap it is starved regardless of the buffers.
|
||||
plant.inputBuffer.counts.clear();
|
||||
REQUIRE(statusOf(plant) == ProductionStatus::Starved);
|
||||
}
|
||||
@@ -1741,7 +1851,7 @@ TEST_CASE("BuildingSystem: getProductionStatus classifies production state", "[b
|
||||
SECTION("Salvage Bay: red when empty, green when holding scrap")
|
||||
{
|
||||
Building bay; bay.type = BuildingType::SalvageBay;
|
||||
bay.outputBuffer.capacity = 20;
|
||||
bay.outputBuffer.caps[ItemType{"scrap"}] = 20;
|
||||
REQUIRE(statusOf(bay) == ProductionStatus::Starved); // empty -> red
|
||||
|
||||
bay.outputBuffer.items = { makeItem("scrap") };
|
||||
|
||||
@@ -75,7 +75,7 @@ BufferedBuildingContent::BufferedBuildingContent(const SelectionContext& context
|
||||
|
||||
m_production = new ProductionSection(this);
|
||||
|
||||
m_outputSection = new SectionBox(tr("Output buffer"), this);
|
||||
m_outputSection = new SectionBox(tr("Output buffers"), this);
|
||||
m_outputChips = new ItemChipRow(context, m_outputSection);
|
||||
m_outputSection->getContentLayout()->addWidget(m_outputChips);
|
||||
|
||||
@@ -200,11 +200,16 @@ std::vector<ItemChipRow::Entry> BufferedBuildingContent::buildOutputEntries(
|
||||
|
||||
ItemChipRow::Entry chip;
|
||||
chip.itemId = itemId;
|
||||
// Counted against the buffer's capacity, which is what production stops at
|
||||
// (REQ-MAT-OUTPUT-BUFFER).
|
||||
chip.countText = building.outputBuffer.capacity > 0
|
||||
? tr("%1 / %2").arg(lookUp(buffered, itemId))
|
||||
.arg(building.outputBuffer.capacity)
|
||||
// Counted against this item's own buffer capacity, which is what production
|
||||
// stops at (REQ-MAT-OUTPUT-BUFFER, REQ-UI-SINGLE-SELECTION). A chip for an item
|
||||
// the building has no buffer for -- one left over from a previous recipe --
|
||||
// carries the bare count, as an unsized buffer has no denominator to state.
|
||||
const std::map<ItemType, int>::const_iterator capIt =
|
||||
building.outputBuffer.caps.find(ItemType{itemId});
|
||||
const int cap =
|
||||
(capIt != building.outputBuffer.caps.end()) ? capIt->second : 0;
|
||||
chip.countText = cap > 0
|
||||
? tr("%1 / %2").arg(lookUp(buffered, itemId)).arg(cap)
|
||||
: QString::number(lookUp(buffered, itemId));
|
||||
chip.subLine = QString::fromStdString(toDisplayName(itemId));
|
||||
entries.push_back(chip);
|
||||
|
||||
Reference in New Issue
Block a user