show implicitly unlocked items in schematic unlock dialog
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user