diff --git a/src/lib/sim/CMakeLists.txt b/src/lib/sim/CMakeLists.txt index d2eac1f..c4ebed0 100644 --- a/src/lib/sim/CMakeLists.txt +++ b/src/lib/sim/CMakeLists.txt @@ -19,6 +19,7 @@ SET(HDRS ${CMAKE_CURRENT_SOURCE_DIR}/ShipStatsCalculator.h ${CMAKE_CURRENT_SOURCE_DIR}/StateChecksum.h ${CMAKE_CURRENT_SOURCE_DIR}/ThreatCostCalculator.h + ${CMAKE_CURRENT_SOURCE_DIR}/UnlockState.h ${CMAKE_CURRENT_SOURCE_DIR}/WaveSystem.h PARENT_SCOPE ) @@ -40,6 +41,7 @@ SET(SRCS ${CMAKE_CURRENT_SOURCE_DIR}/ShipStatsCalculator.cpp ${CMAKE_CURRENT_SOURCE_DIR}/StateChecksum.cpp ${CMAKE_CURRENT_SOURCE_DIR}/ThreatCostCalculator.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/UnlockState.cpp ${CMAKE_CURRENT_SOURCE_DIR}/WaveSystem.cpp PARENT_SCOPE ) diff --git a/src/lib/sim/Simulation.cpp b/src/lib/sim/Simulation.cpp index 27140d2..53275de 100644 --- a/src/lib/sim/Simulation.cpp +++ b/src/lib/sim/Simulation.cpp @@ -6,7 +6,6 @@ #include "AiSystem.h" #include "Command.h" -#include "DisplayName.h" #include "BuildingSystem.h" #include "CombatSystem.h" #include "DynamicBodyComponent.h" @@ -43,6 +42,7 @@ Simulation::Simulation(GameConfig config, unsigned int seed) , m_hqProxyEntity(entt::null) , m_playerStation1Entity(entt::null) , m_playerStation2Entity(entt::null) + , m_unlockState(m_config) , m_beltSystem(m_config.world.beltSpeed_tps) { m_currentEnemyStationEntities[0] = entt::null; @@ -50,7 +50,7 @@ Simulation::Simulation(GameConfig config, unsigned int seed) initializeSubsystems(); - initializeUnlockState(); + m_unlockState.initializeUnlockState(); placeInitialStructures(); registerForEvents(); } @@ -97,7 +97,7 @@ void Simulation::reset(unsigned int seed) m_beltSystem = BeltSystem(m_config.world.beltSpeed_tps); initializeSubsystems(); - initializeUnlockState(); + m_unlockState.initializeUnlockState(); placeInitialStructures(); } @@ -110,9 +110,7 @@ void Simulation::initializeSubsystems() [this](int amount) { m_buildingBlocksStock += amount; }, [this](const std::string& id, QVector2D pos, const std::optional& layout) { - const std::map::const_iterator it = - m_schematicLevels.find(id); - if (it == m_schematicLevels.end() || !it->second.unlocked) + if (!isSchematicUnlocked(id)) { return; } @@ -131,55 +129,6 @@ void Simulation::initializeSubsystems() m_combatSystem = std::make_unique(m_config); } -void Simulation::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(); -} - // --------------------------------------------------------------------------- // tick // --------------------------------------------------------------------------- @@ -613,9 +562,9 @@ void Simulation::generateSchematicChoices(int destroyedStationLevel) std::vector pool; for (const UnlockGroupDef& group : m_config.unlocks.groups) { - if (m_awardedUnlockGroupIds.count(group.id) > 0) { continue; } + if (m_unlockState.isUnlockGroupAwarded(group.id)) { continue; } if (group.stationLevel < 0 || group.stationLevel > destroyedStationLevel) { continue; } - if (!prerequisitesSatisfied(group.requiredGroupIds)) { continue; } + if (!m_unlockState.prerequisitesSatisfied(group.requiredGroupIds)) { continue; } pool.push_back(&group); } @@ -639,7 +588,7 @@ void Simulation::generateSchematicChoices(int destroyedStationLevel) const std::size_t endIdx = pool.size() - 1 - static_cast(i); std::swap(pool[rollIdx], pool[endIdx]); - m_pendingSchematicChoices.push_back(makeUnlockOption(*pool[endIdx])); + m_pendingSchematicChoices.push_back(m_unlockState.makeUnlockOption(*pool[endIdx])); } if (artifactRolled) @@ -651,48 +600,6 @@ void Simulation::generateSchematicChoices(int destroyedStationLevel) } } -SchematicChoiceOption Simulation::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 Simulation::applySchematicChoice(int choiceIndex) { assert(choiceIndex >= 0 && choiceIndex < static_cast(m_pendingSchematicChoices.size())); @@ -709,204 +616,24 @@ void Simulation::applySchematicChoice(int choiceIndex) return; } - // 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(); + m_unlockState.awardUnlockGroup(chosen); m_pendingSchematicChoices.clear(); } -// --------------------------------------------------------------------------- -// Implicit unlock computation (REQ-LOCK-IMPLICIT) -// --------------------------------------------------------------------------- - -void Simulation::recomputeUnlocked() -{ - const UnlockedSets result = computeUnlockedSets( - getUnlockedShipSchematicIds(), getUnlockedModuleSchematicIds(), m_unlockedRecipeSchematicIds); - m_unlockedItemIds = result.itemIds; - m_unlockedRecipeIds = result.recipeIds; -} - -std::set Simulation::getUnlockedShipSchematicIds() const -{ - std::set ids; - for (const auto& [id, state] : m_schematicLevels) - { - if (state.unlocked) { ids.insert(id); } - } - return ids; -} - -std::set Simulation::getUnlockedModuleSchematicIds() const -{ - std::set ids; - for (const auto& [id, state] : m_moduleSchematicLevels) - { - if (state.unlocked) { ids.insert(id); } - } - return ids; -} - -bool Simulation::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; -} - -Simulation::UnlockedSets Simulation::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 Simulation::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; -} - bool Simulation::isRecipeUnlocked(const std::string& recipeId) const { - return m_unlockedRecipeIds.count(recipeId) > 0; + return m_unlockState.isRecipeUnlocked(recipeId); } bool Simulation::isItemUnlocked(const std::string& itemId) const { - return m_unlockedItemIds.count(itemId) > 0; + return m_unlockState.isItemUnlocked(itemId); } // --------------------------------------------------------------------------- // Determinism (see docs/replay_design.md) // --------------------------------------------------------------------------- -void Simulation::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 Simulation::appendStringSet(Hasher& hasher, const std::set& ids) -{ - hasher.append(ids.size()); - for (const std::string& id : ids) - { - hasher.append(id); - } -} - unsigned long long Simulation::getRngFingerprint() const { return fingerprintRng(m_rng); @@ -937,13 +664,7 @@ unsigned long long Simulation::computeStateChecksum() const hasher.append(getNormalGapRemainingTicks()); // Schematic / unlock state (std::map and std::set iterate in sorted order). - 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); + m_unlockState.appendChecksum(hasher); // Subsystems contribute their own state. m_buildingSystem->appendChecksum(hasher); @@ -1114,37 +835,17 @@ Tick Simulation::getNormalGapRemainingTicks() const bool Simulation::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; + return m_unlockState.isSchematicUnlocked(shipId); } bool Simulation::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; + return m_unlockState.isModuleSchematicUnlocked(moduleId); } bool Simulation::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; + return m_unlockState.isBuildingUnlocked(type); } std::optional Simulation::tryPlaceBuilding(BuildingType type, QPoint anchor, Rotation rotation) diff --git a/src/lib/sim/Simulation.h b/src/lib/sim/Simulation.h index bacb185..3c58ec9 100644 --- a/src/lib/sim/Simulation.h +++ b/src/lib/sim/Simulation.h @@ -1,10 +1,8 @@ #pragma once -#include #include #include #include -#include #include #include @@ -22,6 +20,7 @@ #include "Rotation.h" #include "Tick.h" #include "TracePrintRequestedEvent.h" +#include "UnlockState.h" class AiSystem; class BuildingSystem; @@ -203,69 +202,11 @@ private: entt::entity m_playerStation2Entity; entt::entity m_currentEnemyStationEntities[2]; - // Schematic unlock state (REQ-DEF-SCHEMATIC-DROP). - struct SchematicState - { - bool unlocked; - }; - std::map m_schematicLevels; - std::map m_moduleSchematicLevels; - std::map m_buildingLevels; - - // Unlock groups awarded so far (REQ-LOCK-EXPLICIT). Group ids. - std::set m_awardedUnlockGroupIds; - - // Ids granted by some unlock group, per kind — cached from config at init. - // An item starts locked iff it appears in the corresponding set. - std::set m_grantedShipIds; - std::set m_grantedModuleIds; - std::set m_grantedBuildingIds; - std::set m_grantedRecipeIds; - - // Builds the granted-id sets and initializes all per-item unlock maps from - // them (shared by the constructor and reset). Ends with recomputeUnlocked(). - void initializeUnlockState(); - - // Builds a schematic choice option for one unlock group (REQ-DEF-SCHEMATIC-DROP). - SchematicChoiceOption makeUnlockOption(const UnlockGroupDef& group) const; - - // Determinism helpers — fold sub-state into the hasher in deterministic order. - static void appendSchematicMap(Hasher& hasher, - const std::map& levels); - static void appendStringSet(Hasher& hasher, const std::set& ids); - - // Explicitly unlocked assembler recipe schematics (REQ-LOCK-EXPLICIT). - std::set m_unlockedRecipeSchematicIds; - - // Implicit unlock sets derived from schematic state (REQ-LOCK-IMPLICIT). - std::set m_unlockedRecipeIds; - std::set m_unlockedItemIds; - - // Recomputes m_unlockedRecipeIds and m_unlockedItemIds from current schematic state. - void recomputeUnlocked(); - - // Result of the REQ-LOCK-IMPLICIT traversal. - struct UnlockedSets - { - std::set itemIds; - std::set recipeIds; - }; - - // Pure REQ-LOCK-IMPLICIT traversal given hypothetical explicit-unlock sets. - UnlockedSets computeUnlockedSets(const std::set& unlockedShipSchematicIds, - const std::set& unlockedModuleSchematicIds, - const std::set& unlockedRecipeSchematicIds) const; - - // Current explicit-unlock id sets, derived from m_schematicLevels / m_moduleSchematicLevels. - std::set getUnlockedShipSchematicIds() const; - std::set getUnlockedModuleSchematicIds() const; - - // True if every prerequisite unlock group has been awarded (REQ-LOCK-PREREQ). - bool prerequisitesSatisfied(const std::vector& requiredGroupIds) const; - - // Ids (sorted alphabetically by display name) of the recipes in - // hypothetical.recipeIds that are not yet in m_unlockedRecipeIds. - std::vector computeNewlyUnlockedRecipeIds(const UnlockedSets& hypothetical) const; + // Schematic/unlock bookkeeping (REQ-DEF-SCHEMATIC-DROP, REQ-LOCK-EXPLICIT, + // REQ-LOCK-IMPLICIT, REQ-LOCK-BUILDING, REQ-LOCK-PREREQ). Constructed before + // initializeSubsystems() runs since BuildingSystem's spawn-gating lambda + // calls into it (see initializeSubsystems()). + UnlockState m_unlockState; EntityAdmin m_admin; BeltSystem m_beltSystem; diff --git a/src/lib/sim/UnlockState.cpp b/src/lib/sim/UnlockState.cpp new file mode 100644 index 0000000..3ab8591 --- /dev/null +++ b/src/lib/sim/UnlockState.cpp @@ -0,0 +1,352 @@ +#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); +} diff --git a/src/lib/sim/UnlockState.h b/src/lib/sim/UnlockState.h new file mode 100644 index 0000000..5e013f7 --- /dev/null +++ b/src/lib/sim/UnlockState.h @@ -0,0 +1,129 @@ +#pragma once + +#include +#include +#include +#include + +#include "BuildingType.h" +#include "GameConfig.h" +#include "SchematicChoiceOption.h" + +class Hasher; + +// Owns schematic/unlock bookkeeping for one run: which ship, module, and +// building schematics are unlocked (REQ-LOCK-EXPLICIT), which assembler recipe +// schematics have been explicitly granted, and the implicit recipe/item unlock +// sets derived from that state (REQ-LOCK-IMPLICIT). Reads config the same way +// BuildingSystem does (a bound const reference to Simulation::m_config, which is +// safe across restart because that member's storage address never changes — +// reset() move-assigns into it rather than replacing it). +// +// Simulation forwards its isXUnlocked-style public queries here and drives +// state changes (awarding an unlock group) here; the RNG-touching schematic +// choice generation itself stays in Simulation (call ordering of m_rng is the +// determinism backbone and must not move). +class UnlockState +{ +public: + explicit UnlockState(const GameConfig& config); + + // Builds the granted-id sets and initializes all per-item unlock maps from + // them (shared by the constructor and Simulation::reset). Ends with + // recomputeUnlocked(). + void initializeUnlockState(); + + // Ship schematic state query. + bool isSchematicUnlocked(const std::string& shipId) const; + + // Module schematic state query. + bool isModuleSchematicUnlocked(const std::string& moduleId) const; + + // Implicit recipe/item unlock queries (REQ-LOCK-IMPLICIT). + bool isRecipeUnlocked(const std::string& recipeId) const; + bool isItemUnlocked(const std::string& itemId) const; + + // Building unlock query (REQ-LOCK-BUILDING). True if the building type is not + // gated by any unlock group, or its granting group has been awarded. + bool isBuildingUnlocked(BuildingType type) const; + + // True if the unlock group has already been awarded to the player. + bool isUnlockGroupAwarded(const std::string& groupId) const; + + // True if every prerequisite unlock group has been awarded (REQ-LOCK-PREREQ). + bool prerequisitesSatisfied(const std::vector& requiredGroupIds) const; + + // Builds a schematic choice option for one unlock group (REQ-DEF-SCHEMATIC-DROP). + SchematicChoiceOption makeUnlockOption(const UnlockGroupDef& group) const; + + // Awards the unlock group backing `chosen` (REQ-DEF-SCHEMATIC-DROP): marks + // every granted ship/module/building schematic unlocked, records granted + // recipe schematics, marks the group as awarded, and recomputes the implicit + // unlock sets. Mirrors the non-artifact branch of the original + // Simulation::applySchematicChoice exactly; callers still special-case + // chosen.isArtifact themselves before calling this. + void awardUnlockGroup(const SchematicChoiceOption& chosen); + + // Determinism helper (see Simulation::computeStateChecksum): folds unlock + // state into the hasher via the same seven calls, in the same order, that + // used to live at the Simulation::computeStateChecksum call site. + void appendChecksum(Hasher& hasher) const; + +private: + // Schematic unlock state (REQ-DEF-SCHEMATIC-DROP). + struct SchematicState + { + bool unlocked; + }; + + // Recomputes m_unlockedRecipeIds and m_unlockedItemIds from current schematic state. + void recomputeUnlocked(); + + // Result of the REQ-LOCK-IMPLICIT traversal. + struct UnlockedSets + { + std::set itemIds; + std::set recipeIds; + }; + + // Pure REQ-LOCK-IMPLICIT traversal given hypothetical explicit-unlock sets. + UnlockedSets computeUnlockedSets(const std::set& unlockedShipSchematicIds, + const std::set& unlockedModuleSchematicIds, + const std::set& unlockedRecipeSchematicIds) const; + + // Current explicit-unlock id sets, derived from m_schematicLevels / m_moduleSchematicLevels. + std::set getUnlockedShipSchematicIds() const; + std::set getUnlockedModuleSchematicIds() const; + + // Ids (sorted alphabetically by display name) of the recipes in + // hypothetical.recipeIds that are not yet in m_unlockedRecipeIds. + std::vector computeNewlyUnlockedRecipeIds(const UnlockedSets& hypothetical) const; + + // Determinism helpers — fold sub-state into the hasher in deterministic order. + static void appendSchematicMap(Hasher& hasher, + const std::map& levels); + static void appendStringSet(Hasher& hasher, const std::set& ids); + + const GameConfig& m_config; + + std::map m_schematicLevels; + std::map m_moduleSchematicLevels; + std::map m_buildingLevels; + + // Unlock groups awarded so far (REQ-LOCK-EXPLICIT). Group ids. + std::set m_awardedUnlockGroupIds; + + // Ids granted by some unlock group, per kind — cached from config at init. + // An item starts locked iff it appears in the corresponding set. + std::set m_grantedShipIds; + std::set m_grantedModuleIds; + std::set m_grantedBuildingIds; + std::set m_grantedRecipeIds; + + // Explicitly unlocked assembler recipe schematics (REQ-LOCK-EXPLICIT). + std::set m_unlockedRecipeSchematicIds; + + // Implicit unlock sets derived from schematic state (REQ-LOCK-IMPLICIT). + std::set m_unlockedRecipeIds; + std::set m_unlockedItemIds; +};