give every recipe one shape: a list of output groups

Implements REQ-MAT-OUTPUT-GROUP. A recipe had two shapes -- outputs produced
together, or outputs of which exactly one happened -- and every rule over them
was written twice, selected by `building == ReprocessingPlant`: sizing a
buffer, deciding whether a cycle fits, resolving what a cycle makes, costing an
item. RecipeDef now holds output groups, each a weight and a list of items, and
a cycle yields exactly one group. One group is the ordinary recipe, so the old
two cases are the same shape with one and with several, and all four rules
collapse to one expression apiece with no building-type test left.

rollReprocessingOutput becomes rollOutputGroup, where a single group returns
without drawing or testing eligibility. That early-out is load-bearing twice
over. Drawing there would consume entropy for every ordinary recipe and shift
every later random outcome; and eligibility must not apply either, since
implicit unlocking is demand-derived, so an ordinary recipe's output can be
producible while nothing yet calls for it -- testing it would stop the building
producing rather than gate a drop. Past the early-out a group is eligible only
when all of its items are unlocked, being produced whole.

Threat follows the recipe's shape rather than the building, and the per-unit
value now divides by the group's amount as well as its odds. That moves no
number today: every item resolved through this path has amount 1, which is why
the threat expectations are untouched.

Config keeps `outputs = [...]` as the single-group form, so only the two
reprocessing recipes change shape. The recipe summary gains "/" between groups
and keeps "+" within one, which also fixes the plant reading as though a cycle
produced all of its items at once.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ne3mejABZoLWKLh8fgpM3x
This commit is contained in:
2026-08-17 12:47:09 +02:00
parent 41c45d73ce
commit 9c275e283c
27 changed files with 535 additions and 234 deletions

View File

@@ -104,14 +104,20 @@ struct PlacementFixture
BuildingSystem bs;
// Defaults to the configured belt speed; pass kFastBeltSpeed_tps where the test
// needs items to arrive immediately.
explicit PlacementFixture(std::optional<double> beltSpeed_tps = std::nullopt)
// needs items to arrive immediately. Everything counts as unlocked unless the test
// says otherwise, which is what the output-group eligibility rule turns on
// (REQ-LOCK-OUTPUT-POOL).
explicit PlacementFixture(
std::optional<double> beltSpeed_tps = std::nullopt,
std::function<bool(const std::string&)> isItemUnlocked = nullptr)
: belts(beltSpeed_tps.value_or(cfg.world.beltSpeed_tps))
, bs(cfg, belts,
[this]() { return nextBuildingId++; },
[this](int n) { stock += n; },
[](const std::string&, QVector2D, const std::optional<ShipLayoutConfig>&) {},
[](const std::string&) -> bool { return true; },
isItemUnlocked ? std::move(isItemUnlocked)
: std::function<bool(const std::string&)>(
[](const std::string&) { return true; }),
rng)
{
}
@@ -948,6 +954,135 @@ TEST_CASE("BuildingSystem: setRecipe clears output buffer and active production"
// Reprocessing plant -- per-item output buffers (REQ-MAT-OUTPUT-BUFFER)
// ---------------------------------------------------------------------------
TEST_CASE("ConfigLoader: the outputs shorthand and one output_group load alike",
"[config]")
{
// `outputs = [...]` is exactly one group holding those items (REQ-MAT-OUTPUT-GROUP),
// so a recipe written either way behaves identically.
PlacementFixture f;
const RecipeDef* shorthand =
f.cfg.recipes.findRecipeDef("iron_ingot", BuildingType::Smelter);
REQUIRE(shorthand != nullptr);
REQUIRE(shorthand->outputGroups.size() == 1);
REQUIRE_FALSE(shorthand->outputGroups.front().probability.has_value());
// Sizing and the cycle gate read it as one group like any other.
Building smelter; smelter.type = BuildingType::Smelter;
smelter.recipeId = shorthand->id;
initBuffers(smelter, *shorthand);
REQUIRE(smelter.outputBuffer.caps.at(ItemType{"iron_ingot"})
== 2 * shorthand->outputGroups.front().items.front().amount);
REQUIRE(recipeOutputsFit(smelter, *shorthand));
}
TEST_CASE("BuildingSystem: a single-group recipe consumes no randomness", "[building]")
{
// Nothing is picked where there is one group, so no draw is made (REQ-MAT-OUTPUT-GROUP).
// Drawing here would consume entropy for every ordinary recipe and shift every later
// random outcome, which is what the two fixtures below would expose: they differ only
// in how far their generators have been advanced.
PlacementFixture quiet;
PlacementFixture advanced;
for (int i = 0; i < 50; ++i) { (void)advanced.rng(); }
Tick tickA = 0;
Tick tickB = 0;
const BuildingId a =
quiet.bs.place(quiet.state, BuildingType::Miner, QPoint(0, 0), Rotation::East, 0).value();
const BuildingId b =
advanced.bs.place(advanced.state, BuildingType::Miner, QPoint(0, 0), Rotation::East, 0).value();
quiet.bs.setRecipe(quiet.state, a, "mine_iron_ore");
advanced.bs.setRecipe(advanced.state, b, "mine_iron_ore");
const int ticks = static_cast<int>(secondsToTicks(10.0)) + 40;
runTicks(quiet.bs, quiet.cfg, quiet.state, quiet.belts, quiet.stock, ticks, tickA);
runTicks(advanced.bs, advanced.cfg, advanced.state, advanced.belts, advanced.stock,
ticks, tickB);
const Building* minerA = findBuilding(quiet.state, a);
const Building* minerB = findBuilding(advanced.state, b);
REQUIRE(minerA != nullptr);
REQUIRE(minerB != nullptr);
REQUIRE(minerA->getOutputItemCount() > 0);
REQUIRE(minerA->getOutputItemCount() == minerB->getOutputItemCount());
REQUIRE(minerA->production.has_value() == minerB->production.has_value());
}
TEST_CASE("BuildingSystem: a group's items are sized and gated together", "[building]")
{
// A group yields all of its items at once (REQ-MAT-OUTPUT-GROUP), so each is buffered
// at twice its own amount and the cycle needs room for all of them at once. No config
// recipe has a multi-item group yet, so one is built here.
PlacementFixture f;
RecipeDef recipe;
recipe.id = "multi_item_group";
recipe.building = BuildingType::Assembler;
recipe.durationSeconds = 1.0;
recipe.inputs.push_back(RecipeIngredient{"iron_ore", 1});
RecipeOutputGroup group;
group.items.push_back(RecipeOutput{"iron_ingot", 2});
group.items.push_back(RecipeOutput{"silicon", 1});
recipe.outputGroups.push_back(group);
Building assembler; assembler.type = BuildingType::Assembler;
initBuffers(assembler, recipe);
REQUIRE(assembler.outputBuffer.caps.at(ItemType{"iron_ingot"}) == 4);
REQUIRE(assembler.outputBuffer.caps.at(ItemType{"silicon"}) == 2);
// Both fit while both have room.
REQUIRE(recipeOutputsFit(assembler, recipe));
// One item of the group short of room blocks the whole cycle, even though the other
// still has plenty: the group cannot be produced in halves.
assembler.outputBuffer.items.push_back(makeItem("silicon"));
assembler.outputBuffer.items.push_back(makeItem("silicon"));
REQUIRE(outputBufferHasRoom(assembler, ItemType{"iron_ingot"}, 2));
REQUIRE_FALSE(outputBufferHasRoom(assembler, ItemType{"silicon"}, 1));
REQUIRE_FALSE(recipeOutputsFit(assembler, recipe));
}
TEST_CASE("BuildingSystem: a group with a locked item is never picked", "[building]")
{
// A group's items come together, so a group holding any locked item is dropped whole
// (REQ-LOCK-OUTPUT-POOL). Here only circuit_board is unlocked, so every cycle must
// yield that group however the weights are stacked -- iron_ingot's group carries the
// largest weight of the three and would dominate were the filter not applied.
PlacementFixture f(std::nullopt,
[](const std::string& id) { return id == "circuit_board"; });
Tick tick = 0;
const BuildingId id = f.bs.place(f.state, BuildingType::ReprocessingPlant,
QPoint(0, 0), Rotation::East, 0).value();
runTicks(f.bs, f.cfg, f.state, f.belts, f.stock,
static_cast<int>(secondsToTicks(25.0)) + 1, tick);
f.bs.setRecipe(f.state, id, "reprocessing_cycle");
// Run many cycles, refilling the scrap and draining the output each time so the plant
// never stalls. A broken filter would show iron_ingot within a few rounds.
int produced = 0;
for (int round = 0; round < 20; ++round)
{
f.bs.forEachBuilding(f.state, [](Building& building) {
if (building.type != BuildingType::ReprocessingPlant) { return; }
building.inputBuffer.counts[ItemType{"scrap"}] =
building.inputBuffer.caps.at(ItemType{"scrap"});
building.outputBuffer.items.clear();
for (std::vector<BeltItemSlot>& lane : building.emergingItems) { lane.clear(); }
});
runTicks(f.bs, f.cfg, f.state, f.belts, f.stock,
static_cast<int>(secondsToTicks(3.0)) + 1, tick);
for (const Item& item : outputSideItems(*findBuilding(f.state, id)))
{
CHECK(item.type.id == "circuit_board");
++produced;
}
}
REQUIRE(produced > 0);
}
TEST_CASE("BuildingSystem: reprocessing plant sizes one output buffer per possible roll",
"[building]")
{
@@ -1855,9 +1990,10 @@ TEST_CASE("BuildingSystem: getProductionStatus classifies production state", "[b
// Sized the way the simulation sizes it (REQ-MAT-OUTPUT-BUFFER).
initBuffers(assembler, *assemblerRecipe);
const std::string outputItemId = assemblerRecipe->outputs.front().item;
const std::string outputItemId =
assemblerRecipe->outputGroups.front().items.front().item;
int cycleOutput = 0;
for (const RecipeOutput& out : assemblerRecipe->outputs)
for (const RecipeOutput& out : assemblerRecipe->outputGroups.front().items)
{
cycleOutput += out.amount;
}
@@ -1896,17 +2032,21 @@ TEST_CASE("BuildingSystem: getProductionStatus classifies production state", "[b
{
if (r.building != BuildingType::Assembler || r.inputs.empty()) { continue; }
int total = 0;
for (const RecipeOutput& out : r.outputs) { total += out.amount; }
for (const RecipeOutput& out : r.outputGroups.front().items)
{
total += out.amount;
}
if (total >= 2) { multiOutputRecipe = &r; break; }
}
REQUIRE(multiOutputRecipe != nullptr);
int cycleOutput = 0;
for (const RecipeOutput& out : multiOutputRecipe->outputs)
for (const RecipeOutput& out : multiOutputRecipe->outputGroups.front().items)
{
cycleOutput += out.amount;
}
const std::string outputItemId = multiOutputRecipe->outputs.front().item;
const std::string outputItemId =
multiOutputRecipe->outputGroups.front().items.front().item;
Building assembler; assembler.type = BuildingType::Assembler;
assembler.recipeId = multiOutputRecipe->id;
@@ -1945,7 +2085,7 @@ TEST_CASE("BuildingSystem: getProductionStatus classifies production state", "[b
}
}
REQUIRE(reprocessingRecipe != nullptr);
REQUIRE(reprocessingRecipe->outputs.size() >= 2);
REQUIRE(reprocessingRecipe->outputGroups.size() >= 2);
Building plant; plant.type = BuildingType::ReprocessingPlant;
plant.recipeId = reprocessingRecipe->id;
@@ -1960,8 +2100,10 @@ TEST_CASE("BuildingSystem: getProductionStatus classifies production state", "[b
// 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;
const std::string firstItemId =
reprocessingRecipe->outputGroups.front().items.front().item;
const std::string lastItemId =
reprocessingRecipe->outputGroups.back().items.front().item;
for (int i = 0; i < plant.outputBuffer.caps.at(ItemType{firstItemId}); ++i)
{
plant.outputBuffer.items.push_back(makeItem(firstItemId));