unlock recipes by one rule, whatever building runs them

isRecipeUnlocked only ever meant anything for miner and assembler recipes: the
traversal inserted those two and nothing else. So every caller carried the same
branch -- ask the unlock state for a miner or an assembler, ask something else
otherwise -- in the blueprint gate, the selection dialog, and the item tooltip's
producer list, three copies of one exception.

Recipe unlocking moves out of the item traversal into a pass of its own, run
once the item set has settled: a recipe is unlocked when it is not a gated
assembler recipe still awaiting its group, and it has an output group it could
actually yield. Smelter and reprocessing recipes are judged by that too, so the
question is now meaningful for all of them and the three branches collapse to
one call.

The two conditions stay independent on purpose. Explicit gating is about
permission and outlives a wanted output -- a drop-only recipe whose output is
useful anyway stays locked until it is awarded.

Reprocessing still takes no part in the item traversal (REQ-BLD-REPROCESSING):
it is asked in the new pass whether it can yield anything, without its yields
feeding what counts as unlocked. Judging a group whole rather than per item
(REQ-LOCK-OUTPUT-POOL) is what keeps a group's locked companion item from
letting the whole group through.

Recorded replays from before this change will not reproduce: unlocked recipe
ids feed the state checksum, and smelter and reprocessing ids now join them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ne3mejABZoLWKLh8fgpM3x
This commit is contained in:
2026-08-20 17:37:46 +02:00
parent f93252f69b
commit d2b1363f6d
7 changed files with 115 additions and 45 deletions

View File

@@ -1,6 +1,7 @@
#include "UnlockState.h"
#include <algorithm>
#include <functional>
#include "DisplayName.h"
#include "StateChecksum.h"
@@ -251,6 +252,23 @@ UnlockState::UnlockedSets UnlockState::computeUnlockedSets(
}
}
// Skips a gated assembler recipe (granted by an unlock group) whose group has not
// yet been awarded (REQ-LOCK-IMPLICIT step 2, REQ-LOCK-EXPLICIT). This gating is
// about permission and says nothing about what the recipe yields, so it applies
// whether the recipe is propagating demand below or being judged runnable further
// down.
const std::function<bool(const RecipeDef&)> isGatedOff =
[this, &unlockedRecipeSchematicIds](const RecipeDef& recipe)
{
return recipe.building == BuildingType::Assembler
&& m_grantedRecipeIds.count(recipe.id) > 0
&& unlockedRecipeSchematicIds.count(recipe.id) == 0;
};
// Demand propagation: an item is unlocked when something the player can already
// reach calls for it, so a recipe producing an unlocked item unlocks its inputs.
// Reprocessing takes no part in this (REQ-BLD-REPROCESSING): being obtainable from
// a plant must never be what makes an item or its inputs unlocked.
bool changed = true;
while (changed)
{
@@ -263,14 +281,8 @@ UnlockState::UnlockedSets UnlockState::computeUnlockedSets(
{
continue;
}
// Skip a gated assembler recipe (granted by an unlock group) whose
// group has not yet been awarded (REQ-LOCK-IMPLICIT step 2).
if (recipe.building == BuildingType::Assembler
&& m_grantedRecipeIds.count(recipe.id) > 0
&& unlockedRecipeSchematicIds.count(recipe.id) == 0)
{
continue;
}
if (isGatedOff(recipe)) { continue; }
bool producesUnlocked = false;
for (const std::string& item : getProducibleItems(recipe))
{
@@ -282,11 +294,6 @@ UnlockState::UnlockedSets UnlockState::computeUnlockedSets(
}
if (!producesUnlocked) { continue; }
if (recipe.building == BuildingType::Miner
|| recipe.building == BuildingType::Assembler)
{
result.recipeIds.insert(recipe.id);
}
for (const RecipeIngredient& ing : recipe.inputs)
{
if (result.itemIds.insert(ing.item).second)
@@ -297,6 +304,39 @@ UnlockState::UnlockedSets UnlockState::computeUnlockedSets(
}
}
// Which recipes are unlocked, once the item set has settled. One rule for every
// building that runs a recipe: the player may run it when it is permitted at all and
// can currently yield something usable -- some output group all of whose items are
// unlocked (REQ-LOCK-OUTPUT-POOL). Reprocessing is asked the question here without
// having taken part in the traversal above: what it can yield is decided by the item
// set, not the other way round.
const std::function<bool(const std::string&)> isItemUnlockedHere =
[&result](const std::string& itemId)
{
return result.itemIds.count(itemId) > 0;
};
for (const RecipeDef& recipe : m_config.recipes.recipes)
{
if (recipe.building != BuildingType::Miner
&& recipe.building != BuildingType::Smelter
&& recipe.building != BuildingType::Assembler
&& recipe.building != BuildingType::ReprocessingPlant)
{
continue;
}
if (isGatedOff(recipe)) { continue; }
for (const RecipeOutputGroup& group : recipe.outputGroups)
{
if (isOutputGroupUnlocked(group, isItemUnlockedHere))
{
result.recipeIds.insert(recipe.id);
break;
}
}
}
return result;
}