size the output buffer per item instead of per building
Implements the requirements from 8401b20. OutputBuffer's type-blind `capacity`
becomes a `caps` map keyed by item, mirroring InputBuffer. The items stay in
one production-ordered vector, because that order is what the output port hands
out (REQ-MAT-OUTPUT-EMERGE); only the capacity is now per type, so one item's
backlog no longer occupies another's room.
The cycle gate moves ahead of the roll in tickProduction: recipeOutputsFit()
requires every output a cycle could produce to fit, which for a deterministic
recipe is just its own outputs and for the Reprocessing Plant is every possible
roll. That is what denies the reroll the old one-item cap was there for -- a
full buffer for one item stops the plant entirely rather than letting it keep
producing the others -- while letting it hold two cycles' worth per outcome and
produce amounts above one.
getCycleOutputItemCount(), which judged the plant by the smallest amount any
roll could yield, is gone: it only existed because the classifier could not know
the roll, and the all-outcomes rule needs no such approximation. Its unlock
caveat goes with it, since a locked item is never produced and so never fills.
The Salvage Bay's config-defined capacity becomes its single scrap cap, and the
shipyard simply has no caps at all. The checksum hashes the map the way the
input caps are hashed, so determinism is unchanged.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ne3mejABZoLWKLh8fgpM3x
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);
|
||||
addOutputCaps(b.outputBuffer.caps, b.type, recipe);
|
||||
}
|
||||
outputCapacity = std::max(outputCapacity, maxAmount);
|
||||
}
|
||||
else
|
||||
{
|
||||
int totalAmount = 0;
|
||||
for (const RecipeOutput& out : recipe.outputs)
|
||||
{
|
||||
totalAmount += out.amount;
|
||||
}
|
||||
outputCapacity = std::max(outputCapacity, 2 * totalAmount);
|
||||
}
|
||||
}
|
||||
|
||||
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,7 +1679,7 @@ 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.caps[ItemType{"iron_ore"}] = 2;
|
||||
miner.outputBuffer.items = { makeItem("iron_ore"), makeItem("iron_ore") };
|
||||
REQUIRE(statusOf(miner) == ProductionStatus::Blocked); // -> yellow
|
||||
|
||||
@@ -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;
|
||||
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") };
|
||||
|
||||
@@ -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