List unlocked recipes with tooltips in schematic choice dialog instead of unlocked item names

This commit is contained in:
2026-07-09 20:22:17 +02:00
parent 16ed6a5695
commit 6274b5ce60
12 changed files with 136 additions and 80 deletions

View File

@@ -315,11 +315,11 @@ Modules in `modules.toml` define a `surface_mask` — a list of strings that des
Each option in the dialog displays: the schematic name (ship `id` from `ships.toml`, module `id` from `modules.toml`, or assembler recipe `id` from `recipes.toml`) and the schematic type (ship, module, or assembler recipe). The artifact option (if present) is displayed as a distinct entry with the name "Artifact". Each option in the dialog displays: the schematic name (ship `id` from `ships.toml`, module `id` from `modules.toml`, or assembler recipe `id` from `recipes.toml`) and the schematic type (ship, module, or assembler recipe). The artifact option (if present) is displayed as a distinct entry with the name "Artifact".
Each option additionally displays a vertical list of item names labeled "Unlocks recipes for:", showing which recipes would newly become implicitly unlocked (REQ-LOCK-IMPLICIT) if this option were selected — specifically, the output items of miner recipes and assembler recipes (without `unlock_at_station_level`) that are not currently implicitly unlocked but would become so after applying this option's effect: Each option additionally displays a vertical list of recipe names labeled "Unlocks recipes:", showing which miner and assembler recipes would newly become implicitly unlocked (REQ-LOCK-IMPLICIT) if this option were selected — specifically, the miner recipes and assembler recipes (without `unlock_at_station_level`) that are not currently implicitly unlocked but would become so after applying this option's effect:
- For a ship or module schematic, its `materials` are added to the base set per REQ-LOCK-IMPLICIT step 1a before recomputation. - For a ship or module schematic, its `materials` are added to the base set per REQ-LOCK-IMPLICIT step 1a before recomputation.
- For an assembler recipe schematic, its output item is added to the base set per REQ-LOCK-IMPLICIT step 1b before recomputation. - For an assembler recipe schematic, its output item is added to the base set per REQ-LOCK-IMPLICIT step 1b before recomputation.
Item names are deduplicated and sorted alphabetically. If no recipes would be newly unlocked, the list shows "None". Each recipe is listed by its `id` (using the same display convention as the assembler recipe-selection dialog), sorted alphabetically. Hovering a recipe in this list displays the recipe info tooltip described for a recipe in REQ-UI-SELECT-TOOLTIP (the recipe name; the name and quantity of each input item; the completion time; and the name and quantity of the produced output item). If no recipes would be newly unlocked, the list shows "None".
The player selects one option by clicking it. If the player selects the artifact option, the player's artifact count is incremented by 1 (REQ-WIN-ARTIFACT-COUNT) and the dialog closes; no schematic is applied. Otherwise, the selected schematic is applied and the dialog closes: The player selects one option by clicking it. If the player selects the artifact option, the player's artifact count is incremented by 1 (REQ-WIN-ARTIFACT-COUNT) and the dialog closes; no schematic is applied. Otherwise, the selected schematic is applied and the dialog closes:

View File

@@ -20,8 +20,8 @@ struct SchematicChoiceOption
SchematicType type; SchematicType type;
std::string displayName; std::string displayName;
// Display names of items produced by recipes that would newly become // Ids of miner/assembler recipes that would newly become implicitly
// implicitly unlocked (REQ-LOCK-IMPLICIT) if this option is selected. // unlocked (REQ-LOCK-IMPLICIT) if this option is selected. Sorted
// Deduplicated and sorted alphabetically; empty if none. // alphabetically by display name; empty if none.
std::vector<std::string> newlyUnlockedItemNames; std::vector<std::string> newlyUnlockedRecipeIds;
}; };

View File

@@ -730,7 +730,7 @@ void Simulation::generateSchematicChoices(int destroyedStationLevel)
const UnlockedSets hypothetical = computeUnlockedSets( const UnlockedSets hypothetical = computeUnlockedSets(
hypotheticalShipIds, hypotheticalModuleIds, hypotheticalRecipeSchematicIds); hypotheticalShipIds, hypotheticalModuleIds, hypotheticalRecipeSchematicIds);
option.newlyUnlockedItemNames = computeNewlyUnlockedItemNames(hypothetical); option.newlyUnlockedRecipeIds = computeNewlyUnlockedRecipeIds(hypothetical);
m_pendingSchematicChoices.push_back(option); m_pendingSchematicChoices.push_back(option);
} }
@@ -918,23 +918,20 @@ Simulation::UnlockedSets Simulation::computeUnlockedSets(
return result; return result;
} }
std::vector<std::string> Simulation::computeNewlyUnlockedItemNames(const UnlockedSets& hypothetical) const std::vector<std::string> Simulation::computeNewlyUnlockedRecipeIds(const UnlockedSets& hypothetical) const
{ {
std::set<std::string> itemNames; std::vector<std::string> recipeIds;
for (const std::string& recipeId : hypothetical.recipeIds) for (const std::string& recipeId : hypothetical.recipeIds)
{ {
if (m_unlockedRecipeIds.count(recipeId) > 0) { continue; } if (m_unlockedRecipeIds.count(recipeId) > 0) { continue; }
for (const RecipeDef& def : m_config.recipes.recipes) recipeIds.push_back(recipeId);
}
std::sort(recipeIds.begin(), recipeIds.end(),
[](const std::string& lhs, const std::string& rhs)
{ {
if (def.id != recipeId) { continue; } return toDisplayName(lhs) < toDisplayName(rhs);
for (const RecipeOutput& out : def.outputs) });
{ return recipeIds;
itemNames.insert(toDisplayName(out.item));
}
break;
}
}
return std::vector<std::string>(itemNames.begin(), itemNames.end());
} }
bool Simulation::isRecipeUnlocked(const std::string& recipeId) const bool Simulation::isRecipeUnlocked(const std::string& recipeId) const

View File

@@ -233,9 +233,9 @@ private:
// True if every prerequisite in unlockRequires is explicitly unlocked (REQ-LOCK-PREREQ). // True if every prerequisite in unlockRequires is explicitly unlocked (REQ-LOCK-PREREQ).
bool prerequisitesSatisfied(const std::vector<std::string>& unlockRequires) const; bool prerequisitesSatisfied(const std::vector<std::string>& unlockRequires) const;
// Display names (deduplicated, alphabetical) of output items of recipes in // Ids (sorted alphabetically by display name) of the recipes in
// hypothetical.recipeIds that are not yet in m_unlockedRecipeIds. // hypothetical.recipeIds that are not yet in m_unlockedRecipeIds.
std::vector<std::string> computeNewlyUnlockedItemNames(const UnlockedSets& hypothetical) const; std::vector<std::string> computeNewlyUnlockedRecipeIds(const UnlockedSets& hypothetical) const;
EntityAdmin m_admin; EntityAdmin m_admin;
BeltSystem m_beltSystem; BeltSystem m_beltSystem;

View File

@@ -283,7 +283,7 @@ TEST_CASE("RecipeSchematic: reset keeps -1 recipes unlocked and their seed items
// Unlock dialog: newly-unlocked recipe preview (REQ-DEF-SCHEMATIC-DROP) // Unlock dialog: newly-unlocked recipe preview (REQ-DEF-SCHEMATIC-DROP)
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
TEST_CASE("RecipeSchematic: newlyUnlockedItemNames is sorted, deduplicated, and empty for level-ups", TEST_CASE("RecipeSchematic: newlyUnlockedRecipeIds is sorted, deduplicated, and empty for level-ups",
"[recipe_schematic]") "[recipe_schematic]")
{ {
Simulation sim(loadConfig()); Simulation sim(loadConfig());
@@ -295,10 +295,11 @@ TEST_CASE("RecipeSchematic: newlyUnlockedItemNames is sorted, deduplicated, and
for (const SchematicChoiceOption& opt : sim.getPendingSchematicChoices()) for (const SchematicChoiceOption& opt : sim.getPendingSchematicChoices())
{ {
// Strictly ascending implies sorted and deduplicated. // Strictly ascending display names imply sorted and deduplicated.
for (std::size_t j = 1; j < opt.newlyUnlockedItemNames.size(); ++j) for (std::size_t j = 1; j < opt.newlyUnlockedRecipeIds.size(); ++j)
{ {
CHECK(opt.newlyUnlockedItemNames[j - 1] < opt.newlyUnlockedItemNames[j]); CHECK(toDisplayName(opt.newlyUnlockedRecipeIds[j - 1])
< toDisplayName(opt.newlyUnlockedRecipeIds[j]));
} }
} }
@@ -306,7 +307,7 @@ TEST_CASE("RecipeSchematic: newlyUnlockedItemNames is sorted, deduplicated, and
} }
} }
TEST_CASE("RecipeSchematic: newlyUnlockedItemNames matches recipes that actually become unlocked", TEST_CASE("RecipeSchematic: newlyUnlockedRecipeIds matches recipes that actually become unlocked",
"[recipe_schematic]") "[recipe_schematic]")
{ {
Simulation sim(loadConfig()); Simulation sim(loadConfig());
@@ -336,21 +337,22 @@ TEST_CASE("RecipeSchematic: newlyUnlockedItemNames matches recipes that actually
SimulationTestAccess::applySchematicChoice(sim, 0); SimulationTestAccess::applySchematicChoice(sim, 0);
std::set<std::string> expectedNames; std::vector<std::string> expected;
for (const RecipeDef& def : cfg.recipes.recipes) for (const RecipeDef& def : cfg.recipes.recipes)
{ {
if ((def.building == BuildingType::Miner || def.building == BuildingType::Assembler) if ((def.building == BuildingType::Miner || def.building == BuildingType::Assembler)
&& sim.isRecipeUnlocked(def.id) && unlockedBefore.count(def.id) == 0) && sim.isRecipeUnlocked(def.id) && unlockedBefore.count(def.id) == 0)
{ {
for (const RecipeOutput& out : def.outputs) expected.push_back(def.id);
}
}
std::sort(expected.begin(), expected.end(),
[](const std::string& lhs, const std::string& rhs)
{ {
expectedNames.insert(toDisplayName(out.item)); return toDisplayName(lhs) < toDisplayName(rhs);
} });
}
}
const std::vector<std::string> expected(expectedNames.begin(), expectedNames.end());
REQUIRE(choice.newlyUnlockedItemNames == expected); REQUIRE(choice.newlyUnlockedRecipeIds == expected);
} }
} }

View File

@@ -13,6 +13,7 @@ SET(HDRS
${CMAKE_CURRENT_SOURCE_DIR}/ShipStatsPanel.h ${CMAKE_CURRENT_SOURCE_DIR}/ShipStatsPanel.h
${CMAKE_CURRENT_SOURCE_DIR}/SchematicChoiceDialog.h ${CMAKE_CURRENT_SOURCE_DIR}/SchematicChoiceDialog.h
${CMAKE_CURRENT_SOURCE_DIR}/RecipeSelectionDialog.h ${CMAKE_CURRENT_SOURCE_DIR}/RecipeSelectionDialog.h
${CMAKE_CURRENT_SOURCE_DIR}/RecipeTooltip.h
PARENT_SCOPE PARENT_SCOPE
) )
@@ -30,5 +31,6 @@ SET(SRCS
${CMAKE_CURRENT_SOURCE_DIR}/ShipStatsPanel.cpp ${CMAKE_CURRENT_SOURCE_DIR}/ShipStatsPanel.cpp
${CMAKE_CURRENT_SOURCE_DIR}/SchematicChoiceDialog.cpp ${CMAKE_CURRENT_SOURCE_DIR}/SchematicChoiceDialog.cpp
${CMAKE_CURRENT_SOURCE_DIR}/RecipeSelectionDialog.cpp ${CMAKE_CURRENT_SOURCE_DIR}/RecipeSelectionDialog.cpp
${CMAKE_CURRENT_SOURCE_DIR}/RecipeTooltip.cpp
PARENT_SCOPE PARENT_SCOPE
) )

View File

@@ -142,7 +142,7 @@ void MainWindow::handleEvent(std::shared_ptr<const SchematicChoicesAvailableEven
const double prevSpeed = m_gameWorldView->gameSpeed(); const double prevSpeed = m_gameWorldView->gameSpeed();
m_gameWorldView->setGameSpeed(0.0); m_gameWorldView->setGameSpeed(0.0);
SchematicChoiceDialog dialog(event->choices, this); SchematicChoiceDialog dialog(event->choices, m_sim->config().recipes, this);
dialog.exec(); dialog.exec();
std::shared_ptr<ApplySchematicChoiceCommand> command = std::shared_ptr<ApplySchematicChoiceCommand> command =

View File

@@ -12,6 +12,7 @@
#include "DisplayName.h" #include "DisplayName.h"
#include "GameConfig.h" #include "GameConfig.h"
#include "RecipesConfig.h" #include "RecipesConfig.h"
#include "RecipeTooltip.h"
#include "ShipsConfig.h" #include "ShipsConfig.h"
#include "Simulation.h" #include "Simulation.h"
@@ -25,39 +26,6 @@ QString itemLine(const std::string& itemId, int amount)
+ QStringLiteral(" ×") + QString::number(amount); + QStringLiteral(" ×") + QString::number(amount);
} }
QString recipeTooltip(const RecipeDef& recipe)
{
QStringList lines;
lines << QObject::tr("Recipe: %1")
.arg(QString::fromStdString(toDisplayName(recipe.id)));
if (recipe.inputs.empty())
{
lines << QObject::tr("Inputs: none");
}
else
{
lines << QObject::tr("Inputs:");
for (const RecipeIngredient& ingredient : recipe.inputs)
{
lines << itemLine(ingredient.item, ingredient.amount);
}
}
lines << QObject::tr("Completion time: %1 s").arg(recipe.durationSeconds);
if (!recipe.outputs.empty())
{
lines << QObject::tr("Produces:");
for (const RecipeOutput& output : recipe.outputs)
{
lines << itemLine(output.item, output.amount);
}
}
return lines.join('\n');
}
QString shipTooltip(const ShipDef& def) QString shipTooltip(const ShipDef& def)
{ {
const QString name = QString::fromStdString(toDisplayName(def.id)); const QString name = QString::fromStdString(toDisplayName(def.id));
@@ -116,7 +84,7 @@ std::vector<RecipeSelectionOption> buildRecipeSelectionOptions(
} }
options.push_back({recipe.id, options.push_back({recipe.id,
QString::fromStdString(toDisplayName(recipe.id)), QString::fromStdString(toDisplayName(recipe.id)),
recipeTooltip(recipe)}); buildRecipeTooltip(recipe)});
} }
} }

52
src/ui/RecipeTooltip.cpp Normal file
View File

@@ -0,0 +1,52 @@
#include "RecipeTooltip.h"
#include <QObject>
#include <QStringList>
#include "DisplayName.h"
#include "RecipesConfig.h"
namespace
{
QString itemLine(const std::string& itemId, int amount)
{
return QStringLiteral(" ")
+ QString::fromStdString(toDisplayName(itemId))
+ QStringLiteral(" ×") + QString::number(amount);
}
} // namespace
QString buildRecipeTooltip(const RecipeDef& recipe)
{
QStringList lines;
lines << QObject::tr("Recipe: %1")
.arg(QString::fromStdString(toDisplayName(recipe.id)));
if (recipe.inputs.empty())
{
lines << QObject::tr("Inputs: none");
}
else
{
lines << QObject::tr("Inputs:");
for (const RecipeIngredient& ingredient : recipe.inputs)
{
lines << itemLine(ingredient.item, ingredient.amount);
}
}
lines << QObject::tr("Completion time: %1 s").arg(recipe.durationSeconds);
if (!recipe.outputs.empty())
{
lines << QObject::tr("Produces:");
for (const RecipeOutput& output : recipe.outputs)
{
lines << itemLine(output.item, output.amount);
}
}
return lines.join('\n');
}

11
src/ui/RecipeTooltip.h Normal file
View File

@@ -0,0 +1,11 @@
#pragma once
#include <QString>
struct RecipeDef;
// Builds the recipe info tooltip text (REQ-UI-SELECT-TOOLTIP): the recipe name,
// each input item name and quantity, the completion time, and the produced
// output item name and quantity. Shared by the recipe-selection dialog and the
// schematic choice dialog's "Unlocks recipes:" list so both render identically.
QString buildRecipeTooltip(const RecipeDef& recipe);

View File

@@ -3,11 +3,29 @@
#include <QHBoxLayout> #include <QHBoxLayout>
#include <QLabel> #include <QLabel>
#include <QPushButton> #include <QPushButton>
#include <QStringList>
#include <QVBoxLayout> #include <QVBoxLayout>
#include "DisplayName.h"
#include "RecipeTooltip.h"
#include "RecipesConfig.h"
namespace
{
const RecipeDef* findRecipe(const RecipesConfig& recipes, const std::string& id)
{
for (const RecipeDef& recipe : recipes.recipes)
{
if (recipe.id == id) { return &recipe; }
}
return nullptr;
}
} // namespace
SchematicChoiceDialog::SchematicChoiceDialog( SchematicChoiceDialog::SchematicChoiceDialog(
const std::vector<SchematicChoiceOption>& options, const std::vector<SchematicChoiceOption>& options,
const RecipesConfig& recipes,
QWidget* parent) QWidget* parent)
: QDialog(parent) : QDialog(parent)
, m_chosenIndex(0) , m_chosenIndex(0)
@@ -70,30 +88,33 @@ SchematicChoiceDialog::SchematicChoiceDialog(
typeLabel->setAlignment(Qt::AlignCenter); typeLabel->setAlignment(Qt::AlignCenter);
cardLayout->addWidget(typeLabel); cardLayout->addWidget(typeLabel);
QLabel* unlocksHeaderLabel = new QLabel(tr("Unlocks recipes for:"), card); QLabel* unlocksHeaderLabel = new QLabel(tr("Unlocks recipes:"), card);
QFont unlocksHeaderFont = unlocksHeaderLabel->font(); QFont unlocksHeaderFont = unlocksHeaderLabel->font();
unlocksHeaderFont.setBold(true); unlocksHeaderFont.setBold(true);
unlocksHeaderLabel->setFont(unlocksHeaderFont); unlocksHeaderLabel->setFont(unlocksHeaderFont);
unlocksHeaderLabel->setAlignment(Qt::AlignCenter); unlocksHeaderLabel->setAlignment(Qt::AlignCenter);
cardLayout->addWidget(unlocksHeaderLabel); cardLayout->addWidget(unlocksHeaderLabel);
QString unlocksText; if (option.newlyUnlockedRecipeIds.empty())
if (option.newlyUnlockedItemNames.empty())
{ {
unlocksText = tr("None"); QLabel* noneLabel = new QLabel(tr("None"), card);
noneLabel->setAlignment(Qt::AlignCenter);
cardLayout->addWidget(noneLabel);
} }
else else
{ {
QStringList itemLines; for (const std::string& recipeId : option.newlyUnlockedRecipeIds)
for (const std::string& itemName : option.newlyUnlockedItemNames)
{ {
itemLines << QString::fromStdString(itemName); QLabel* recipeLabel = new QLabel(
QString::fromStdString(toDisplayName(recipeId)), card);
recipeLabel->setAlignment(Qt::AlignCenter);
if (const RecipeDef* def = findRecipe(recipes, recipeId))
{
recipeLabel->setToolTip(buildRecipeTooltip(*def));
}
cardLayout->addWidget(recipeLabel);
} }
unlocksText = itemLines.join("\n");
} }
QLabel* unlocksLabel = new QLabel(unlocksText, card);
unlocksLabel->setAlignment(Qt::AlignCenter);
cardLayout->addWidget(unlocksLabel);
} }
QPushButton* selectButton = new QPushButton(tr("Select"), card); QPushButton* selectButton = new QPushButton(tr("Select"), card);

View File

@@ -6,12 +6,15 @@
#include "SchematicChoiceOption.h" #include "SchematicChoiceOption.h"
struct RecipesConfig;
class SchematicChoiceDialog : public QDialog class SchematicChoiceDialog : public QDialog
{ {
Q_OBJECT Q_OBJECT
public: public:
SchematicChoiceDialog(const std::vector<SchematicChoiceOption>& options, SchematicChoiceDialog(const std::vector<SchematicChoiceOption>& options,
const RecipesConfig& recipes,
QWidget* parent = nullptr); QWidget* parent = nullptr);
int getChosenIndex() const; int getChosenIndex() const;