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:
2026-08-12 21:45:09 +02:00
parent 0b859bd1a4
commit 4ee6438405
11 changed files with 358 additions and 206 deletions

View File

@@ -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);

View File

@@ -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") };