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
194 lines
6.5 KiB
C++
194 lines
6.5 KiB
C++
#include "RecipeSelectionDialog.h"
|
|
|
|
#include <cstddef>
|
|
|
|
#include <QFont>
|
|
#include <QFrame>
|
|
#include <QLabel>
|
|
#include <QPushButton>
|
|
#include <QScrollArea>
|
|
#include <QVBoxLayout>
|
|
|
|
#include "Building.h"
|
|
#include "BuildingType.h"
|
|
#include "DisplayName.h"
|
|
#include "GameConfig.h"
|
|
#include "OptionButton.h"
|
|
#include "RecipesConfig.h"
|
|
#include "ShipsConfig.h"
|
|
#include "Simulation.h"
|
|
|
|
namespace
|
|
{
|
|
|
|
std::vector<RecipeLineRow::Amount> toAmounts(
|
|
const std::vector<RecipeIngredient>& ingredients)
|
|
{
|
|
std::vector<RecipeLineRow::Amount> amounts;
|
|
amounts.reserve(ingredients.size());
|
|
for (const RecipeIngredient& ingredient : ingredients)
|
|
{
|
|
amounts.push_back(RecipeLineRow::Amount{ ingredient.item, ingredient.amount });
|
|
}
|
|
return amounts;
|
|
}
|
|
|
|
|
|
} // namespace
|
|
|
|
std::vector<RecipeSelectionOption> buildRecipeSelectionOptions(
|
|
BuildingType type, Simulation& sim, const GameConfig& config)
|
|
{
|
|
std::vector<RecipeSelectionOption> options;
|
|
// The clearing option. On a building that picks its own recipe it does not leave the
|
|
// building idle but hands it back to that selection, and says so
|
|
// (REQ-UI-SELECT-OPTIONS, REQ-BLD-AUTO-RECIPE).
|
|
options.push_back({std::string(),
|
|
isAutoRecipeBuildingType(type) ? QObject::tr("(Auto)")
|
|
: QObject::tr("(None)"),
|
|
RecipeLineRow::Spec()});
|
|
|
|
if (type == BuildingType::Shipyard)
|
|
{
|
|
for (const ShipDef& def : config.ships.ships)
|
|
{
|
|
if (!sim.isSchematicUnlocked(def.id)) { continue; }
|
|
|
|
// The base materials and base production time, excluding the modules the
|
|
// player has not placed yet (REQ-UI-SELECT-OPTIONS). No output item: a ship
|
|
// has no icon, so the caption is what the option produces.
|
|
RecipeLineRow::Spec line;
|
|
line.inputs = toAmounts(def.schematic.materials);
|
|
line.durationSeconds = def.schematic.productionTimeSeconds;
|
|
|
|
options.push_back({def.id,
|
|
QString::fromStdString(toDisplayName(def.id)),
|
|
line});
|
|
}
|
|
}
|
|
else
|
|
{
|
|
for (const RecipeDef& recipe : config.recipes.recipes)
|
|
{
|
|
if (recipe.building != type) { continue; }
|
|
if ((type == BuildingType::Miner
|
|
|| type == BuildingType::Assembler)
|
|
&& !sim.isRecipeUnlocked(recipe.id))
|
|
{
|
|
continue;
|
|
}
|
|
|
|
RecipeLineRow::Spec line;
|
|
line.inputs = toAmounts(recipe.inputs);
|
|
line.outputGroups = RecipeLineRow::toOutputGroups(recipe);
|
|
line.durationSeconds = recipe.durationSeconds;
|
|
|
|
options.push_back({recipe.id,
|
|
QString::fromStdString(toDisplayName(recipe.id)),
|
|
line});
|
|
}
|
|
}
|
|
|
|
return options;
|
|
}
|
|
|
|
namespace
|
|
{
|
|
// Height the option list is allowed to take, as the dialog's parent window's height
|
|
// less what the window margins and the dialog's own frame need. Fully unlocked, an
|
|
// Assembler offers more options than any window can hold, and the modal is never
|
|
// resized to fit (REQ-UI-PANEL-MODAL) -- so the list scrolls instead.
|
|
const int kListHeightReservePx = 80;
|
|
|
|
// Fallback cap for a dialog built without a parent window to measure.
|
|
const int kFallbackListHeightPx = 600;
|
|
}
|
|
|
|
RecipeSelectionDialog::RecipeSelectionDialog(
|
|
const std::vector<RecipeSelectionOption>& options,
|
|
const QString& title, ItemIconCache* itemIcons, BuildingIconCache* buildingIcons,
|
|
QWidget* parent)
|
|
: QDialog(parent)
|
|
{
|
|
setWindowTitle(title);
|
|
setModal(true);
|
|
|
|
QVBoxLayout* mainLayout = new QVBoxLayout(this);
|
|
|
|
// One vertical column of buttons, each stating what it makes (REQ-UI-SELECT-OPTIONS).
|
|
QScrollArea* scrollArea = new QScrollArea(this);
|
|
scrollArea->setWidgetResizable(true);
|
|
scrollArea->setFrameShape(QFrame::NoFrame);
|
|
scrollArea->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff);
|
|
scrollArea->setSizeAdjustPolicy(QAbstractScrollArea::AdjustToContents);
|
|
scrollArea->setMaximumHeight(parent != nullptr
|
|
? qMax(kListHeightReservePx, parent->height() - kListHeightReservePx)
|
|
: kFallbackListHeightPx);
|
|
mainLayout->addWidget(scrollArea);
|
|
|
|
QWidget* list = new QWidget(scrollArea);
|
|
QVBoxLayout* listLayout = new QVBoxLayout(list);
|
|
listLayout->setContentsMargins(0, 0, 0, 0);
|
|
listLayout->setSpacing(4);
|
|
|
|
for (int i = 0; i < static_cast<int>(options.size()); ++i)
|
|
{
|
|
const RecipeSelectionOption& option = options[static_cast<std::size_t>(i)];
|
|
|
|
// An OptionButton so the face of widgets below is what sizes the button
|
|
// (REQ-UI-SELECT-OPTIONS).
|
|
OptionButton* button = new OptionButton(list);
|
|
if (option.line.isEmpty())
|
|
{
|
|
// The "(None)" option makes nothing and is a caption alone
|
|
// (REQ-UI-SELECT-OPTIONS).
|
|
button->setText(option.caption);
|
|
}
|
|
else
|
|
{
|
|
// The name over the recipe it stands for. Both are transparent to the mouse
|
|
// so a click anywhere on the face still reaches the button beneath them.
|
|
QVBoxLayout* face = new QVBoxLayout(button);
|
|
face->setContentsMargins(8, 6, 8, 6);
|
|
face->setSpacing(2);
|
|
|
|
QLabel* nameLabel = new QLabel(option.caption, button);
|
|
QFont nameFont = nameLabel->font();
|
|
nameFont.setBold(true);
|
|
nameLabel->setFont(nameFont);
|
|
nameLabel->setAttribute(Qt::WA_TransparentForMouseEvents, true);
|
|
face->addWidget(nameLabel);
|
|
|
|
RecipeLineRow* line = new RecipeLineRow(itemIcons, buildingIcons, button);
|
|
line->setAttribute(Qt::WA_TransparentForMouseEvents, true);
|
|
face->addWidget(line);
|
|
line->setLine(option.line);
|
|
}
|
|
listLayout->addWidget(button);
|
|
|
|
m_optionIds.push_back(option.id);
|
|
const int index = i;
|
|
connect(button, &QPushButton::clicked, this, [this, index]()
|
|
{
|
|
onOptionClicked(index);
|
|
});
|
|
}
|
|
listLayout->addStretch(1);
|
|
|
|
scrollArea->setWidget(list);
|
|
}
|
|
|
|
std::optional<std::string> RecipeSelectionDialog::getChosenId() const
|
|
{
|
|
return m_chosenId;
|
|
}
|
|
|
|
void RecipeSelectionDialog::onOptionClicked(int index)
|
|
{
|
|
if (index >= 0 && index < static_cast<int>(m_optionIds.size()))
|
|
{
|
|
m_chosenId = m_optionIds[static_cast<std::size_t>(index)];
|
|
}
|
|
accept();
|
|
}
|