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:
@@ -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));
|
||||
|
||||
Reference in New Issue
Block a user