Files
dota_factory/src/ui/RecipeSelectionDialog.cpp
Malte Langkabel 9c275e283c 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
2026-08-17 12:47:09 +02:00

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();
}