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/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; +};