show implicitly unlocked items in schematic unlock dialog

This commit is contained in:
2026-06-13 18:01:59 +02:00
parent 5317f35198
commit 3716c2b734
6 changed files with 230 additions and 12 deletions

View File

@@ -276,6 +276,13 @@ Modules in `modules.toml` define a `surface_mask` — a list of strings that des
Each option in the dialog displays: the schematic name (ship `display_name` from `ships.toml`, module `id` from `modules.toml`, or the output item type for assembler recipes), the schematic type (ship, module, or assembler recipe), and whether selecting it would be a **new unlock** or a **level-up** (showing the target level for level-ups). Assembler recipe schematics are always new unlocks since they are removed from the pool once awarded.
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:
- For a ship or module schematic that would be a **new unlock**, its `materials` are added to the base set per REQ-LOCK-IMPLICIT step 1a before recomputation.
- For a ship or module schematic **level-up**, the implicit unlock set is unchanged, so the list is always empty.
- 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".
The player selects one option by clicking it. The selected schematic is applied and the dialog closes:
For a **ship or module schematic**: if the player does not yet have the schematic, it is unlocked (ship schematics unlock the corresponding shipyard selection; module schematics unlock the module type for placement in the layout configuration dialog (REQ-MOD-UI-DIALOG)). If the player already has it, the schematic's `player_production_level` is incremented by 1 — for ship schematics, subsequent ships of that type are produced at a higher level; for module schematics, all instances of that module type use the higher level in their stat formulas.

View File

@@ -1,6 +1,7 @@
#pragma once
#include <string>
#include <vector>
enum class SchematicType
{
@@ -19,4 +20,9 @@ struct SchematicChoiceOption
std::string displayName;
bool isNewUnlock;
int targetLevel;
// Display names of items produced by recipes that would newly become
// implicitly unlocked (REQ-LOCK-IMPLICIT) if this option is selected.
// Deduplicated and sorted alphabetically; empty if none.
std::vector<std::string> newlyUnlockedItemNames;
};

View File

@@ -579,6 +579,9 @@ void Simulation::generateSchematicChoices(int destroyedStationLevel)
const int numChoices = std::min(static_cast<int>(pool.size()), 3);
m_pendingSchematicChoices.clear();
const std::set<std::string> currentShipIds = getUnlockedShipSchematicIds();
const std::set<std::string> currentModuleIds = getUnlockedModuleSchematicIds();
for (int i = 0; i < numChoices; ++i)
{
std::uniform_int_distribution<int> dist(0, static_cast<int>(pool.size()) - 1 - i);
@@ -622,6 +625,28 @@ void Simulation::generateSchematicChoices(int destroyedStationLevel)
}
}
// REQ-DEF-SCHEMATIC-DROP: preview recipes newly implicitly unlocked by this option.
std::set<std::string> hypotheticalShipIds = currentShipIds;
std::set<std::string> hypotheticalModuleIds = currentModuleIds;
std::set<std::string> hypotheticalRecipeSchematicIds = m_unlockedRecipeSchematicIds;
if (entry.type == DropType::Ship && option.isNewUnlock)
{
hypotheticalShipIds.insert(entry.id);
}
else if (entry.type == DropType::Module && option.isNewUnlock)
{
hypotheticalModuleIds.insert(entry.id);
}
else if (entry.type == DropType::Recipe)
{
hypotheticalRecipeSchematicIds.insert(entry.id);
}
const UnlockedSets hypothetical = computeUnlockedSets(
hypotheticalShipIds, hypotheticalModuleIds, hypotheticalRecipeSchematicIds);
option.newlyUnlockedItemNames = computeNewlyUnlockedItemNames(hypothetical);
m_pendingSchematicChoices.push_back(option);
}
}
@@ -654,34 +679,64 @@ void Simulation::applySchematicChoice(int choiceIndex)
void Simulation::recomputeUnlocked()
{
m_unlockedItemIds.clear();
m_unlockedRecipeIds.clear();
const UnlockedSets result = computeUnlockedSets(
getUnlockedShipSchematicIds(), getUnlockedModuleSchematicIds(), m_unlockedRecipeSchematicIds);
m_unlockedItemIds = result.itemIds;
m_unlockedRecipeIds = result.recipeIds;
}
std::set<std::string> Simulation::getUnlockedShipSchematicIds() const
{
std::set<std::string> ids;
for (const auto& [id, state] : m_schematicLevels)
{
if (state.unlocked) { ids.insert(id); }
}
return ids;
}
std::set<std::string> Simulation::getUnlockedModuleSchematicIds() const
{
std::set<std::string> ids;
for (const auto& [id, state] : m_moduleSchematicLevels)
{
if (state.unlocked) { ids.insert(id); }
}
return ids;
}
Simulation::UnlockedSets Simulation::computeUnlockedSets(
const std::set<std::string>& unlockedShipSchematicIds,
const std::set<std::string>& unlockedModuleSchematicIds,
const std::set<std::string>& unlockedRecipeSchematicIds) const
{
UnlockedSets result;
for (const ShipDef& def : m_config.ships.ships)
{
if (!isSchematicUnlocked(def.id)) { continue; }
if (unlockedShipSchematicIds.count(def.id) == 0) { continue; }
for (const RecipeIngredient& mat : def.schematic.materials)
{
m_unlockedItemIds.insert(mat.item);
result.itemIds.insert(mat.item);
}
}
for (const ModuleDef& def : m_config.modules.modules)
{
if (!isModuleSchematicUnlocked(def.id)) { continue; }
if (unlockedModuleSchematicIds.count(def.id) == 0) { continue; }
for (const RecipeIngredient& mat : def.materials)
{
m_unlockedItemIds.insert(mat.item);
result.itemIds.insert(mat.item);
}
}
for (const RecipeDef& def : m_config.recipes.recipes)
{
if (def.building == BuildingType::Assembler
&& def.unlockAtStationLevel.has_value()
&& m_unlockedRecipeSchematicIds.count(def.id) > 0)
&& unlockedRecipeSchematicIds.count(def.id) > 0)
{
for (const RecipeOutput& out : def.outputs)
{
m_unlockedItemIds.insert(out.item);
result.itemIds.insert(out.item);
}
}
}
@@ -700,14 +755,14 @@ void Simulation::recomputeUnlocked()
}
if (recipe.building == BuildingType::Assembler
&& recipe.unlockAtStationLevel.has_value()
&& m_unlockedRecipeSchematicIds.count(recipe.id) == 0)
&& unlockedRecipeSchematicIds.count(recipe.id) == 0)
{
continue;
}
bool producesUnlocked = false;
for (const RecipeOutput& out : recipe.outputs)
{
if (m_unlockedItemIds.count(out.item) > 0)
if (result.itemIds.count(out.item) > 0)
{
producesUnlocked = true;
break;
@@ -718,17 +773,38 @@ void Simulation::recomputeUnlocked()
if (recipe.building == BuildingType::Miner
|| recipe.building == BuildingType::Assembler)
{
m_unlockedRecipeIds.insert(recipe.id);
result.recipeIds.insert(recipe.id);
}
for (const RecipeIngredient& ing : recipe.inputs)
{
if (m_unlockedItemIds.insert(ing.item).second)
if (result.itemIds.insert(ing.item).second)
{
changed = true;
}
}
}
}
return result;
}
std::vector<std::string> Simulation::computeNewlyUnlockedItemNames(const UnlockedSets& hypothetical) const
{
std::set<std::string> itemNames;
for (const std::string& recipeId : hypothetical.recipeIds)
{
if (m_unlockedRecipeIds.count(recipeId) > 0) { continue; }
for (const RecipeDef& def : m_config.recipes.recipes)
{
if (def.id != recipeId) { continue; }
for (const RecipeOutput& out : def.outputs)
{
itemNames.insert(toDisplayName(out.item));
}
break;
}
}
return std::vector<std::string>(itemNames.begin(), itemNames.end());
}
bool Simulation::isRecipeUnlocked(const std::string& recipeId) const

View File

@@ -154,6 +154,26 @@ private:
// Recomputes m_unlockedRecipeIds and m_unlockedItemIds from current schematic state.
void recomputeUnlocked();
// Result of the REQ-LOCK-IMPLICIT traversal.
struct UnlockedSets
{
std::set<std::string> itemIds;
std::set<std::string> recipeIds;
};
// Pure REQ-LOCK-IMPLICIT traversal given hypothetical explicit-unlock sets.
UnlockedSets computeUnlockedSets(const std::set<std::string>& unlockedShipSchematicIds,
const std::set<std::string>& unlockedModuleSchematicIds,
const std::set<std::string>& unlockedRecipeSchematicIds) const;
// Current explicit-unlock id sets, derived from m_schematicLevels / m_moduleSchematicLevels.
std::set<std::string> getUnlockedShipSchematicIds() const;
std::set<std::string> getUnlockedModuleSchematicIds() const;
// Display names (deduplicated, alphabetical) of output items of recipes in
// hypothetical.recipeIds that are not yet in m_unlockedRecipeIds.
std::vector<std::string> computeNewlyUnlockedItemNames(const UnlockedSets& hypothetical) const;
EntityAdmin m_admin;
BeltSystem m_beltSystem;
std::unique_ptr<BuildingSystem> m_buildingSystem;

View File

@@ -3,6 +3,7 @@
#include "catch.hpp"
#include "ConfigLoader.h"
#include "DisplayName.h"
#include "FactionComponent.h"
#include "GameConfig.h"
#include "HealthComponent.h"
@@ -277,3 +278,85 @@ TEST_CASE("RecipeSchematic: reset keeps -1 recipes unlocked and their seed items
REQUIRE(sim.isItemUnlocked("premium_circuit"));
}
// ---------------------------------------------------------------------------
// Unlock dialog: newly-unlocked recipe preview (REQ-DEF-SCHEMATIC-DROP)
// ---------------------------------------------------------------------------
TEST_CASE("RecipeSchematic: newlyUnlockedItemNames is sorted, deduplicated, and empty for level-ups",
"[recipe_schematic]")
{
Simulation sim(loadConfig());
for (int i = 0; i < 100; ++i)
{
killEnemyStations(sim);
if (!sim.hasSchematicChoicesPending()) { continue; }
for (const SchematicChoiceOption& opt : sim.getPendingSchematicChoices())
{
// Strictly ascending implies sorted and deduplicated.
for (std::size_t j = 1; j < opt.newlyUnlockedItemNames.size(); ++j)
{
CHECK(opt.newlyUnlockedItemNames[j - 1] < opt.newlyUnlockedItemNames[j]);
}
// A level-up doesn't change the explicit unlock state, so the
// implicit unlock set - and thus this preview - must be empty.
if (!opt.isNewUnlock)
{
CHECK(opt.newlyUnlockedItemNames.empty());
}
}
sim.applySchematicChoice(0);
}
}
TEST_CASE("RecipeSchematic: newlyUnlockedItemNames matches recipes that actually become unlocked",
"[recipe_schematic]")
{
Simulation sim(loadConfig());
const GameConfig cfg = loadConfig();
auto unlockedTrackedRecipeIds = [&]()
{
std::set<std::string> ids;
for (const RecipeDef& def : cfg.recipes.recipes)
{
if ((def.building == BuildingType::Miner || def.building == BuildingType::Assembler)
&& sim.isRecipeUnlocked(def.id))
{
ids.insert(def.id);
}
}
return ids;
};
for (int i = 0; i < 100; ++i)
{
killEnemyStations(sim);
if (!sim.hasSchematicChoicesPending()) { continue; }
const std::set<std::string> unlockedBefore = unlockedTrackedRecipeIds();
const SchematicChoiceOption choice = sim.getPendingSchematicChoices()[0];
sim.applySchematicChoice(0);
std::set<std::string> expectedNames;
for (const RecipeDef& def : cfg.recipes.recipes)
{
if ((def.building == BuildingType::Miner || def.building == BuildingType::Assembler)
&& sim.isRecipeUnlocked(def.id) && unlockedBefore.count(def.id) == 0)
{
for (const RecipeOutput& out : def.outputs)
{
expectedNames.insert(toDisplayName(out.item));
}
}
}
const std::vector<std::string> expected(expectedNames.begin(), expectedNames.end());
REQUIRE(choice.newlyUnlockedItemNames == expected);
}
}

View File

@@ -3,6 +3,7 @@
#include <QHBoxLayout>
#include <QLabel>
#include <QPushButton>
#include <QStringList>
#include <QVBoxLayout>
SchematicChoiceDialog::SchematicChoiceDialog(
@@ -74,6 +75,31 @@ SchematicChoiceDialog::SchematicChoiceDialog(
statusLabel->setAlignment(Qt::AlignCenter);
cardLayout->addWidget(statusLabel);
QLabel* unlocksHeaderLabel = new QLabel(tr("Unlocks recipes for:"), card);
QFont unlocksHeaderFont = unlocksHeaderLabel->font();
unlocksHeaderFont.setBold(true);
unlocksHeaderLabel->setFont(unlocksHeaderFont);
unlocksHeaderLabel->setAlignment(Qt::AlignCenter);
cardLayout->addWidget(unlocksHeaderLabel);
QString unlocksText;
if (option.newlyUnlockedItemNames.empty())
{
unlocksText = tr("None");
}
else
{
QStringList itemLines;
for (const std::string& itemName : option.newlyUnlockedItemNames)
{
itemLines << QString::fromStdString(itemName);
}
unlocksText = itemLines.join("\n");
}
QLabel* unlocksLabel = new QLabel(unlocksText, card);
unlocksLabel->setAlignment(Qt::AlignCenter);
cardLayout->addWidget(unlocksLabel);
QPushButton* selectButton = new QPushButton(tr("Select"), card);
cardLayout->addWidget(selectButton);