Implements REQ-MAT-OUTPUT-GROUP. A recipe had two shapes -- outputs produced together, or outputs of which exactly one happened -- and every rule over them was written twice, selected by `building == ReprocessingPlant`: sizing a buffer, deciding whether a cycle fits, resolving what a cycle makes, costing an item. RecipeDef now holds output groups, each a weight and a list of items, and a cycle yields exactly one group. One group is the ordinary recipe, so the old two cases are the same shape with one and with several, and all four rules collapse to one expression apiece with no building-type test left. rollReprocessingOutput becomes rollOutputGroup, where a single group returns without drawing or testing eligibility. That early-out is load-bearing twice over. Drawing there would consume entropy for every ordinary recipe and shift every later random outcome; and eligibility must not apply either, since implicit unlocking is demand-derived, so an ordinary recipe's output can be producible while nothing yet calls for it -- testing it would stop the building producing rather than gate a drop. Past the early-out a group is eligible only when all of its items are unlocked, being produced whole. Threat follows the recipe's shape rather than the building, and the per-unit value now divides by the group's amount as well as its odds. That moves no number today: every item resolved through this path has amount 1, which is why the threat expectations are untouched. Config keeps `outputs = [...]` as the single-group form, so only the two reprocessing recipes change shape. The recipe summary gains "/" between groups and keeps "+" within one, which also fixes the plant reading as though a cycle produced all of its items at once. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Ne3mejABZoLWKLh8fgpM3x
353 lines
12 KiB
C++
353 lines
12 KiB
C++
#include "UnlockState.h"
|
|
|
|
#include <algorithm>
|
|
|
|
#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<std::string, SchematicState>::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<std::string, SchematicState>::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<std::string, SchematicState>::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<std::string>& 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<std::string> hypotheticalShipIds = getUnlockedShipSchematicIds();
|
|
std::set<std::string> hypotheticalModuleIds = getUnlockedModuleSchematicIds();
|
|
std::set<std::string> 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<std::string> UnlockState::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> UnlockState::getUnlockedModuleSchematicIds() const
|
|
{
|
|
std::set<std::string> ids;
|
|
for (const auto& [id, state] : m_moduleSchematicLevels)
|
|
{
|
|
if (state.unlocked) { ids.insert(id); }
|
|
}
|
|
return ids;
|
|
}
|
|
|
|
UnlockState::UnlockedSets UnlockState::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 (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 std::string& item : getProducibleItems(def))
|
|
{
|
|
result.itemIds.insert(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 std::string& item : getProducibleItems(recipe))
|
|
{
|
|
if (result.itemIds.count(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<std::string> UnlockState::computeNewlyUnlockedRecipeIds(const UnlockedSets& hypothetical) const
|
|
{
|
|
std::vector<std::string> 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<std::string, SchematicState>& levels)
|
|
{
|
|
hasher.append(levels.size());
|
|
for (const std::pair<const std::string, SchematicState>& entry : levels)
|
|
{
|
|
hasher.append(entry.first);
|
|
hasher.append(entry.second.unlocked);
|
|
}
|
|
}
|
|
|
|
void UnlockState::appendStringSet(Hasher& hasher, const std::set<std::string>& 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);
|
|
}
|