#include "UnlockState.h" #include #include "DisplayName.h" #include "StateChecksum.h" UnlockState::UnlockState(const GameConfig& config) : m_config(config) { } void UnlockState::initializeUnlockState() { // Cache the ids granted by some unlock group (REQ-LOCK-EXPLICIT); an item // starts locked iff it is granted by a group. m_grantedShipIds.clear(); m_grantedModuleIds.clear(); m_grantedBuildingIds.clear(); m_grantedRecipeIds.clear(); for (const UnlockGroupDef& group : m_config.unlocks.groups) { m_grantedShipIds.insert(group.ships.begin(), group.ships.end()); m_grantedModuleIds.insert(group.modules.begin(), group.modules.end()); m_grantedBuildingIds.insert(group.buildings.begin(), group.buildings.end()); m_grantedRecipeIds.insert(group.recipes.begin(), group.recipes.end()); } m_awardedUnlockGroupIds.clear(); m_schematicLevels.clear(); for (const ShipDef& def : m_config.ships.ships) { SchematicState state; state.unlocked = (m_grantedShipIds.count(def.id) == 0); m_schematicLevels[def.id] = state; } m_moduleSchematicLevels.clear(); for (const ModuleDef& def : m_config.modules.modules) { SchematicState state; state.unlocked = (m_grantedModuleIds.count(def.id) == 0); m_moduleSchematicLevels[def.id] = state; } m_buildingLevels.clear(); for (const BuildingDef& def : m_config.buildings.buildings) { SchematicState state; state.unlocked = (m_grantedBuildingIds.count(def.id) == 0); m_buildingLevels[def.id] = state; } // Gated assembler recipes start locked; unlocked_at_start recipes are handled // in the REQ-LOCK-IMPLICIT traversal, not tracked here. m_unlockedRecipeSchematicIds.clear(); recomputeUnlocked(); } bool UnlockState::isSchematicUnlocked(const std::string& shipId) const { const std::map::const_iterator it = m_schematicLevels.find(shipId); if (it == m_schematicLevels.end()) { return false; } return it->second.unlocked; } bool UnlockState::isModuleSchematicUnlocked(const std::string& moduleId) const { const std::map::const_iterator it = m_moduleSchematicLevels.find(moduleId); if (it == m_moduleSchematicLevels.end()) { return false; } return it->second.unlocked; } bool UnlockState::isRecipeUnlocked(const std::string& recipeId) const { return m_unlockedRecipeIds.count(recipeId) > 0; } bool UnlockState::isItemUnlocked(const std::string& itemId) const { return m_unlockedItemIds.count(itemId) > 0; } bool UnlockState::isBuildingUnlocked(BuildingType type) const { const BuildingDef* def = m_config.buildings.findBuildingDef(type); if (def == nullptr) { // Types without a config entry (e.g. HQ, defence stations) are unrestricted. return true; } const std::map::const_iterator it = m_buildingLevels.find(def->id); return it == m_buildingLevels.end() ? true : it->second.unlocked; } bool UnlockState::isUnlockGroupAwarded(const std::string& groupId) const { return m_awardedUnlockGroupIds.count(groupId) > 0; } bool UnlockState::prerequisitesSatisfied(const std::vector& requiredGroupIds) const { // A prerequisite is satisfied only once the named unlock group has been // awarded (REQ-LOCK-PREREQ). for (const std::string& groupId : requiredGroupIds) { if (m_awardedUnlockGroupIds.count(groupId) == 0) { return false; } } return true; } SchematicChoiceOption UnlockState::makeUnlockOption(const UnlockGroupDef& group) const { SchematicChoiceOption option; option.isArtifact = false; option.unlockGroupId = group.id; option.displayName = toDisplayName(group.id); for (const std::string& id : group.ships) { option.grantedItems.push_back({SchematicType::Ship, id, toDisplayName(id)}); } for (const std::string& id : group.modules) { option.grantedItems.push_back({SchematicType::Module, id, toDisplayName(id)}); } for (const std::string& id : group.buildings) { option.grantedItems.push_back({SchematicType::Building, id, toDisplayName(id)}); } for (const std::string& id : group.recipes) { option.grantedItems.push_back({SchematicType::Recipe, id, toDisplayName(id)}); } // REQ-DEF-SCHEMATIC-DROP: preview recipes newly implicitly unlocked by // awarding this whole group. Seed the hypothetical explicit-unlock sets with // every grant (ship + module materials via step 1a, recipe outputs via step // 1b), then diff against the current implicit set. std::set hypotheticalShipIds = getUnlockedShipSchematicIds(); std::set hypotheticalModuleIds = getUnlockedModuleSchematicIds(); std::set hypotheticalRecipeSchematicIds = m_unlockedRecipeSchematicIds; for (const std::string& id : group.ships) { hypotheticalShipIds.insert(id); } for (const std::string& id : group.modules) { hypotheticalModuleIds.insert(id); } for (const std::string& id : group.recipes) { hypotheticalRecipeSchematicIds.insert(id); } const UnlockedSets hypothetical = computeUnlockedSets( hypotheticalShipIds, hypotheticalModuleIds, hypotheticalRecipeSchematicIds); option.newlyUnlockedRecipeIds = computeNewlyUnlockedRecipeIds(hypothetical); return option; } void UnlockState::awardUnlockGroup(const SchematicChoiceOption& chosen) { // Award the whole unlock group (REQ-DEF-SCHEMATIC-DROP): unlock every granted // ship, module, building, and assembler recipe at once. m_awardedUnlockGroupIds.insert(chosen.unlockGroupId); for (const GrantedSchematic& grant : chosen.grantedItems) { switch (grant.type) { case SchematicType::Ship: m_schematicLevels.at(grant.id).unlocked = true; break; case SchematicType::Module: m_moduleSchematicLevels.at(grant.id).unlocked = true; break; case SchematicType::Building: m_buildingLevels.at(grant.id).unlocked = true; break; case SchematicType::Recipe: m_unlockedRecipeSchematicIds.insert(grant.id); break; } } recomputeUnlocked(); } // --------------------------------------------------------------------------- // Implicit unlock computation (REQ-LOCK-IMPLICIT) // --------------------------------------------------------------------------- void UnlockState::recomputeUnlocked() { const UnlockedSets result = computeUnlockedSets( getUnlockedShipSchematicIds(), getUnlockedModuleSchematicIds(), m_unlockedRecipeSchematicIds); m_unlockedItemIds = result.itemIds; m_unlockedRecipeIds = result.recipeIds; } std::set UnlockState::getUnlockedShipSchematicIds() const { std::set ids; for (const auto& [id, state] : m_schematicLevels) { if (state.unlocked) { ids.insert(id); } } return ids; } std::set UnlockState::getUnlockedModuleSchematicIds() const { std::set ids; for (const auto& [id, state] : m_moduleSchematicLevels) { if (state.unlocked) { ids.insert(id); } } return ids; } UnlockState::UnlockedSets UnlockState::computeUnlockedSets( const std::set& unlockedShipSchematicIds, const std::set& unlockedModuleSchematicIds, const std::set& unlockedRecipeSchematicIds) const { UnlockedSets result; for (const ShipDef& def : m_config.ships.ships) { if (unlockedShipSchematicIds.count(def.id) == 0) { continue; } for (const RecipeIngredient& mat : def.schematic.materials) { result.itemIds.insert(mat.item); } } for (const ModuleDef& def : m_config.modules.modules) { if (unlockedModuleSchematicIds.count(def.id) == 0) { continue; } for (const RecipeIngredient& mat : def.materials) { result.itemIds.insert(mat.item); } } for (const RecipeDef& def : m_config.recipes.recipes) { // An assembler recipe seeds the base set when it is explicitly available: // flagged unlocked_at_start (base recipes the graph can't reach), or a // gated recipe whose unlock group has been awarded (REQ-LOCK-EXPLICIT). if (def.building == BuildingType::Assembler && (def.unlockedAtStart || unlockedRecipeSchematicIds.count(def.id) > 0)) { for (const RecipeOutput& out : def.outputs) { result.itemIds.insert(out.item); } } } bool changed = true; while (changed) { changed = false; for (const RecipeDef& recipe : m_config.recipes.recipes) { if (recipe.building != BuildingType::Miner && recipe.building != BuildingType::Smelter && recipe.building != BuildingType::Assembler) { 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; } bool producesUnlocked = false; for (const RecipeOutput& out : recipe.outputs) { if (result.itemIds.count(out.item) > 0) { producesUnlocked = true; break; } } 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) { changed = true; } } } } return result; } std::vector UnlockState::computeNewlyUnlockedRecipeIds(const UnlockedSets& hypothetical) const { std::vector recipeIds; for (const std::string& recipeId : hypothetical.recipeIds) { if (m_unlockedRecipeIds.count(recipeId) > 0) { continue; } recipeIds.push_back(recipeId); } std::sort(recipeIds.begin(), recipeIds.end(), [](const std::string& lhs, const std::string& rhs) { return toDisplayName(lhs) < toDisplayName(rhs); }); return recipeIds; } // --------------------------------------------------------------------------- // Determinism (see docs/replay_design.md) // --------------------------------------------------------------------------- void UnlockState::appendSchematicMap(Hasher& hasher, const std::map& levels) { hasher.append(levels.size()); for (const std::pair& entry : levels) { hasher.append(entry.first); hasher.append(entry.second.unlocked); } } void UnlockState::appendStringSet(Hasher& hasher, const std::set& ids) { hasher.append(ids.size()); for (const std::string& id : ids) { hasher.append(id); } } void UnlockState::appendChecksum(Hasher& hasher) const { appendSchematicMap(hasher, m_schematicLevels); appendSchematicMap(hasher, m_moduleSchematicLevels); appendSchematicMap(hasher, m_buildingLevels); appendStringSet(hasher, m_awardedUnlockGroupIds); appendStringSet(hasher, m_unlockedRecipeSchematicIds); appendStringSet(hasher, m_unlockedRecipeIds); appendStringSet(hasher, m_unlockedItemIds); }