Implement config-driven unlock groups so that multiple things can be unlocked at once (including buildings)

This commit is contained in:
2026-07-22 21:34:59 +02:00
parent a8a6a04f1e
commit a9082c57f3
36 changed files with 1005 additions and 543 deletions

View File

@@ -8,6 +8,7 @@ SET(HDRS
${CMAKE_CURRENT_SOURCE_DIR}/StationsConfig.h
${CMAKE_CURRENT_SOURCE_DIR}/GameConfig.h
${CMAKE_CURRENT_SOURCE_DIR}/ModulesConfig.h
${CMAKE_CURRENT_SOURCE_DIR}/UnlocksConfig.h
${CMAKE_CURRENT_SOURCE_DIR}/ConfigLoader.h
${CMAKE_CURRENT_SOURCE_DIR}/SurfaceMask.h
${CMAKE_CURRENT_SOURCE_DIR}/BlueprintSerializer.h

View File

@@ -404,18 +404,10 @@ RecipesConfig ConfigLoader::loadRecipes(const std::string& path)
}
def.building = *parsedType;
if (def.building == BuildingType::Assembler)
if (def.building == BuildingType::Assembler && mt.contains("unlocked_at_start"))
{
const auto level = mt["unlock_at_station_level"].value<int64_t>();
if (level)
{
def.unlockAtStationLevel = static_cast<int>(*level);
}
if (mt.contains("unlock_requires"))
{
def.unlockRequires = requireStringArray(mt["unlock_requires"], file,
elemPath + ".unlock_requires");
}
def.unlockedAtStart = requireBool(mt["unlocked_at_start"], file,
elemPath + ".unlocked_at_start");
}
// inputs may be omitted (e.g. miner recipes). An empty array is fine.
@@ -454,11 +446,6 @@ ShipsConfig ConfigLoader::loadShips(const std::string& path)
ShipDef def;
def.id = requireString(mt["id"], file, elemPath + ".id");
def.unlockAtStationLevel = static_cast<int>(requireInt(mt["unlock_at_station_level"], file, elemPath + ".unlock_at_station_level"));
if (mt.contains("unlock_requires"))
{
def.unlockRequires = requireStringArray(mt["unlock_requires"], file, elemPath + ".unlock_requires");
}
def.layout = requireStringArray(mt["layout"], file, elemPath + ".layout");
// Schematic
@@ -608,12 +595,6 @@ ModulesConfig ConfigLoader::loadModules(const std::string& path)
ModuleDef def;
def.id = requireString(mt["id"], file, elemPath + ".id");
def.unlockAtStationLevel = static_cast<int>(
mt["unlock_at_station_level"].value_or<int64_t>(-1));
if (mt.contains("unlock_requires"))
{
def.unlockRequires = requireStringArray(mt["unlock_requires"], file, elemPath + ".unlock_requires");
}
def.surfaceMask = requireStringArray(mt["surface_mask"], file, elemPath + ".surface_mask");
def.productionTimeSeconds = requireDouble(
mt["production_time_seconds"], file, elemPath + ".production_time_seconds");
@@ -731,62 +712,141 @@ ModulesConfig ConfigLoader::loadModules(const std::string& path)
return cfg;
}
UnlocksConfig ConfigLoader::loadUnlocks(const std::string& path)
{
const std::string file = "unlocks.toml";
toml::table tbl = parseFile(path, file);
UnlocksConfig cfg;
if (!tbl.contains("unlock"))
{
return cfg;
}
const toml::array& arr = requireArray(tbl["unlock"], file, "unlock");
for (std::size_t i = 0; i < arr.size(); ++i)
{
const std::string elemPath = "unlock[" + std::to_string(i) + "]";
const toml::table* ut = arr[i].as_table();
if (ut == nullptr)
{
throw makeError(file, elemPath, "not a table");
}
toml::table& mt = const_cast<toml::table&>(*ut);
UnlockGroupDef def;
def.id = requireString(mt["id"], file, elemPath + ".id");
def.stationLevel = static_cast<int>(
requireInt(mt["station_level"], file, elemPath + ".station_level"));
if (mt.contains("requires"))
{
def.requiredGroupIds = requireStringArray(mt["requires"], file, elemPath + ".requires");
}
if (mt.contains("ships"))
{
def.ships = requireStringArray(mt["ships"], file, elemPath + ".ships");
}
if (mt.contains("modules"))
{
def.modules = requireStringArray(mt["modules"], file, elemPath + ".modules");
}
if (mt.contains("buildings"))
{
def.buildings = requireStringArray(mt["buildings"], file, elemPath + ".buildings");
}
if (mt.contains("recipes"))
{
def.recipes = requireStringArray(mt["recipes"], file, elemPath + ".recipes");
}
cfg.groups.push_back(std::move(def));
}
return cfg;
}
namespace
{
// Throws if any id in requiredIds is not a valid explicitly-unlockable schematic.
void checkUnlockRequires(const std::vector<std::string>& requiredIds,
const std::unordered_set<std::string>& schematicIds,
const std::string& file,
const std::string& path)
// Validates unlocks.toml against the rest of the config (REQ-LOCK-EXPLICIT,
// REQ-LOCK-PREREQ): each granted id resolves to the right kind of definition
// (recipe grants must name assembler recipes), each grantable id is granted by
// at most one group, group ids are unique, every group grants at least one
// item, and every `requires` id names a defined group.
void validateUnlocks(const GameConfig& cfg)
{
for (const std::string& requiredId : requiredIds)
{
if (schematicIds.count(requiredId) == 0)
{
throw makeError(file, path,
"references unknown schematic '" + requiredId + "'");
}
}
}
const std::string file = "unlocks.toml";
// Validates that every id listed in an `unlock_requires` (REQ-LOCK-PREREQ)
// resolves to an explicitly-unlockable schematic: a ship, a module, or an
// assembler recipe that carries `unlock_at_station_level` (a recipe schematic,
// per REQ-LOCK-EXPLICIT). An unresolved id is a config error surfaced at load.
void validateUnlockRequires(const GameConfig& cfg)
{
std::unordered_set<std::string> schematicIds;
for (const ShipDef& def : cfg.ships.ships)
{
schematicIds.insert(def.id);
}
for (const ModuleDef& def : cfg.modules.modules)
{
schematicIds.insert(def.id);
}
std::unordered_set<std::string> shipIds;
for (const ShipDef& def : cfg.ships.ships) { shipIds.insert(def.id); }
std::unordered_set<std::string> moduleIds;
for (const ModuleDef& def : cfg.modules.modules) { moduleIds.insert(def.id); }
std::unordered_set<std::string> buildingIds;
for (const BuildingDef& def : cfg.buildings.buildings) { buildingIds.insert(def.id); }
std::unordered_set<std::string> assemblerRecipeIds;
for (const RecipeDef& def : cfg.recipes.recipes)
{
if (def.building == BuildingType::Assembler && def.unlockAtStationLevel.has_value())
{
schematicIds.insert(def.id);
}
if (def.building == BuildingType::Assembler) { assemblerRecipeIds.insert(def.id); }
}
for (const ShipDef& def : cfg.ships.ships)
std::unordered_set<std::string> groupIds;
std::unordered_set<std::string> grantedShipIds;
std::unordered_set<std::string> grantedModuleIds;
std::unordered_set<std::string> grantedBuildingIds;
std::unordered_set<std::string> grantedRecipeIds;
const auto checkGrants = [&](const std::vector<std::string>& ids,
const std::unordered_set<std::string>& valid,
std::unordered_set<std::string>& granted,
const std::string& kind,
const std::string& gPath)
{
checkUnlockRequires(def.unlockRequires, schematicIds, "ships.toml",
"ship '" + def.id + "'.unlock_requires");
for (const std::string& id : ids)
{
if (valid.count(id) == 0)
{
throw makeError(file, gPath, "grants unknown " + kind + " '" + id + "'");
}
if (!granted.insert(id).second)
{
throw makeError(file, gPath,
"grants " + kind + " '" + id + "' which is already granted by another unlock group");
}
}
};
for (const UnlockGroupDef& group : cfg.unlocks.groups)
{
const std::string gPath = "unlock '" + group.id + "'";
if (!groupIds.insert(group.id).second)
{
throw makeError(file, gPath, "duplicate unlock group id");
}
if (group.ships.empty() && group.modules.empty()
&& group.buildings.empty() && group.recipes.empty())
{
throw makeError(file, gPath, "grants no items (must grant at least one)");
}
checkGrants(group.ships, shipIds, grantedShipIds, "ship", gPath);
checkGrants(group.modules, moduleIds, grantedModuleIds, "module", gPath);
checkGrants(group.buildings, buildingIds, grantedBuildingIds, "building", gPath);
checkGrants(group.recipes, assemblerRecipeIds, grantedRecipeIds, "assembler recipe", gPath);
}
for (const ModuleDef& def : cfg.modules.modules)
// requires must reference defined group ids (checked once all group ids known).
for (const UnlockGroupDef& group : cfg.unlocks.groups)
{
checkUnlockRequires(def.unlockRequires, schematicIds, "modules.toml",
"module '" + def.id + "'.unlock_requires");
}
for (const RecipeDef& def : cfg.recipes.recipes)
{
checkUnlockRequires(def.unlockRequires, schematicIds, "recipes.toml",
"recipe '" + def.id + "'.unlock_requires");
for (const std::string& req : group.requiredGroupIds)
{
if (groupIds.count(req) == 0)
{
throw makeError(file, "unlock '" + group.id + "'.requires",
"references unknown unlock group '" + req + "'");
}
}
}
}
@@ -801,7 +861,8 @@ GameConfig ConfigLoader::loadFromDirectory(const std::string& configDir)
cfg.ships = loadShips(configDir + "/ships.toml");
cfg.stations = loadStations(configDir + "/stations.toml");
cfg.modules = loadModules(configDir + "/modules.toml");
validateUnlockRequires(cfg);
cfg.unlocks = loadUnlocks(configDir + "/unlocks.toml");
validateUnlocks(cfg);
cfg.threatCosts = computeThreatCostTable(cfg);
return cfg;
}

View File

@@ -22,4 +22,5 @@ public:
static ShipsConfig loadShips(const std::string& path);
static StationsConfig loadStations(const std::string& path);
static ModulesConfig loadModules(const std::string& path);
static UnlocksConfig loadUnlocks(const std::string& path);
};

View File

@@ -6,6 +6,7 @@
#include "ShipsConfig.h"
#include "StationsConfig.h"
#include "ModulesConfig.h"
#include "UnlocksConfig.h"
#include "ThreatCostCalculator.h"
// Aggregate of all simulation config files. Loaded at startup and reloaded
@@ -18,5 +19,6 @@ struct GameConfig
ShipsConfig ships;
StationsConfig stations;
ModulesConfig modules;
UnlocksConfig unlocks;
ThreatCostTable threatCosts;
};

View File

@@ -40,10 +40,6 @@ struct ModuleRepairCapability
struct ModuleDef
{
std::string id;
int unlockAtStationLevel;
// Prerequisite schematic ids that must be explicitly unlocked before this
// schematic can enter the drop pool (REQ-LOCK-PREREQ). Empty = none.
std::vector<std::string> unlockRequires;
std::vector<std::string> surfaceMask;
std::vector<RecipeIngredient> materials;
double productionTimeSeconds;

View File

@@ -32,14 +32,12 @@ struct RecipeDef
std::vector<RecipeIngredient> inputs;
std::vector<RecipeOutput> outputs;
double durationSeconds;
// Assembler only. nullopt = implicit-only locking. -1 = explicitly unlocked
// at game start. >= 0 = locked; schematic enters drop pool at that station
// level once the output item is implicitly unlocked (REQ-LOCK-EXPLICIT).
std::optional<int> unlockAtStationLevel;
// Assembler recipe schematics only. Prerequisite schematic ids that must be
// explicitly unlocked before this schematic can enter the drop pool
// (REQ-LOCK-PREREQ). Empty = none.
std::vector<std::string> unlockRequires;
// Assembler only. When true, this recipe is available from game start
// regardless of the implicit item graph — used for base recipes that no
// schematic's materials reach (e.g. building blocks). See REQ-LOCK-IMPLICIT.
// Otherwise an assembler recipe is either explicitly gated (granted by an
// unlock group, REQ-LOCK-EXPLICIT) or implicitly gated via the item graph.
bool unlockedAtStart = false;
};
struct RecipesConfig

View File

@@ -35,10 +35,6 @@ struct ShipSensor
struct ShipDef
{
std::string id;
int unlockAtStationLevel;
// Prerequisite schematic ids that must be explicitly unlocked before this
// schematic can enter the drop pool (REQ-LOCK-PREREQ). Empty = none.
std::vector<std::string> unlockRequires;
std::vector<std::string> layout;
ShipSchematic schematic;

View File

@@ -0,0 +1,26 @@
#pragma once
#include <string>
#include <vector>
// A single [[unlock]] entry from unlocks.toml — an unlock group, the unit of
// loot awarded by defence station drops (REQ-DEF-SCHEMATIC-DROP,
// REQ-LOCK-EXPLICIT). Awarding a group unlocks all of its granted members at
// once. Anything not granted by any group is available from game start.
struct UnlockGroupDef
{
std::string id; // Unique unlock-group id.
int stationLevel; // Min destroyed station level to become eligible.
std::vector<std::string> requiredGroupIds; // Prerequisite unlock-group ids (REQ-LOCK-PREREQ).
// Granted ids by kind (each defaults to empty; a group grants >= 1 item).
std::vector<std::string> ships;
std::vector<std::string> modules;
std::vector<std::string> buildings;
std::vector<std::string> recipes; // Assembler recipe ids only.
};
struct UnlocksConfig
{
std::vector<UnlockGroupDef> groups;
};

View File

@@ -3,23 +3,43 @@
#include <string>
#include <vector>
// The kind of a single granted schematic within an unlock option.
enum class SchematicType
{
Ship,
Module,
Recipe,
Artifact
Building,
Recipe
};
// One item granted by an unlock group (REQ-LOCK-EXPLICIT), shown in the
// schematic choice dialog's granted-items list.
struct GrantedSchematic
{
SchematicType type;
std::string id;
std::string displayName;
};
// One option presented to the player in the schematic choice dialog
// (REQ-DEF-SCHEMATIC-DROP). Built by the simulation when enemy stations are
// destroyed; the UI reads these to populate the dialog.
// destroyed; the UI reads these to populate the dialog. A non-artifact option
// represents one unlock group; selecting it awards the whole group.
struct SchematicChoiceOption
{
std::string schematicId;
SchematicType type;
bool isArtifact = false;
// Unlock group id (empty for the artifact option).
std::string unlockGroupId;
// Display name shown as the option's title: the unlock group's derived name,
// or "Artifact" for the artifact option.
std::string displayName;
// The ships, modules, buildings, and assembler recipes this group grants
// (empty for the artifact option).
std::vector<GrantedSchematic> grantedItems;
// Ids of miner/assembler recipes that would newly become implicitly
// unlocked (REQ-LOCK-IMPLICIT) if this option is selected. Sorted
// alphabetically by display name; empty if none.

View File

@@ -12,6 +12,7 @@ SET(HDRS
${CMAKE_CURRENT_SOURCE_DIR}/GameOverEvent.h
${CMAKE_CURRENT_SOURCE_DIR}/WinEvent.h
${CMAKE_CURRENT_SOURCE_DIR}/ArtifactCountChangedEvent.h
${CMAKE_CURRENT_SOURCE_DIR}/UnlockedBuildingsChangedEvent.h
${CMAKE_CURRENT_SOURCE_DIR}/BuilderModeExitedEvent.h
${CMAKE_CURRENT_SOURCE_DIR}/BlueprintModeExitedEvent.h
${CMAKE_CURRENT_SOURCE_DIR}/EscapeMenuRequestedEvent.h

View File

@@ -0,0 +1,13 @@
#ifndef UNLOCKED_BUILDINGS_CHANGED_EVENT_H
#define UNLOCKED_BUILDINGS_CHANGED_EVENT_H
#include "Event.h"
// Emitted when the set of unlocked building types changes (REQ-LOCK-BUILDING),
// i.e. after an unlock group granting a building is awarded (REQ-DEF-SCHEMATIC-DROP)
// or on Restart. The build button grid re-evaluates which buttons are shown.
class UnlockedBuildingsChangedEvent : public Event
{
};
#endif // UNLOCKED_BUILDINGS_CHANGED_EVENT_H

View File

@@ -75,34 +75,7 @@ Simulation::Simulation(GameConfig config, unsigned int seed)
m_waveSystem = std::make_unique<WaveSystem>(m_config, m_rng);
m_combatSystem = std::make_unique<CombatSystem>(m_config);
// Initialize ship schematic unlock state.
for (const ShipDef& def : m_config.ships.ships)
{
SchematicState state;
state.unlocked = (def.unlockAtStationLevel == -1);
m_schematicLevels[def.id] = state;
}
// Initialize module schematic unlock state.
for (const ModuleDef& def : m_config.modules.modules)
{
SchematicState state;
state.unlocked = (def.unlockAtStationLevel == -1);
m_moduleSchematicLevels[def.id] = state;
}
// Initialize assembler recipe schematic unlock state.
for (const RecipeDef& def : m_config.recipes.recipes)
{
if (def.building == BuildingType::Assembler
&& def.unlockAtStationLevel.has_value()
&& def.unlockAtStationLevel.value() == -1)
{
m_unlockedRecipeSchematicIds.insert(def.id);
}
}
recomputeUnlocked();
initializeUnlockState();
placeInitialStructures();
registerForEvents();
}
@@ -174,11 +147,33 @@ void Simulation::reset(unsigned int seed)
m_waveSystem = std::make_unique<WaveSystem>(m_config, m_rng);
m_combatSystem = std::make_unique<CombatSystem>(m_config);
initializeUnlockState();
placeInitialStructures();
}
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 = (def.unlockAtStationLevel == -1);
state.unlocked = (m_grantedShipIds.count(def.id) == 0);
m_schematicLevels[def.id] = state;
}
@@ -186,23 +181,23 @@ void Simulation::reset(unsigned int seed)
for (const ModuleDef& def : m_config.modules.modules)
{
SchematicState state;
state.unlocked = (def.unlockAtStationLevel == -1);
state.unlocked = (m_grantedModuleIds.count(def.id) == 0);
m_moduleSchematicLevels[def.id] = state;
}
m_unlockedRecipeSchematicIds.clear();
for (const RecipeDef& def : m_config.recipes.recipes)
m_buildingLevels.clear();
for (const BuildingDef& def : m_config.buildings.buildings)
{
if (def.building == BuildingType::Assembler
&& def.unlockAtStationLevel.has_value()
&& def.unlockAtStationLevel.value() == -1)
{
m_unlockedRecipeSchematicIds.insert(def.id);
}
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();
placeInitialStructures();
}
// ---------------------------------------------------------------------------
@@ -628,43 +623,16 @@ void Simulation::tickDeathsAndLoot()
void Simulation::generateSchematicChoices(int destroyedStationLevel)
{
enum class DropType { Ship, Module, Recipe };
struct PoolEntry { std::string id; DropType type; };
std::vector<PoolEntry> pool;
// Owned schematics leave the pool (REQ-DEF-SCHEMATIC-DROP): only offer
// ship/module schematics that are gated to a station level in range and not
// yet unlocked. Schematics with unlock_at_station_level -1 start unlocked, so
// they are always owned and never eligible.
for (const ShipDef& def : m_config.ships.ships)
// Build the eligible pool of unlock groups (REQ-DEF-SCHEMATIC-DROP,
// REQ-LOCK-EXPLICIT, REQ-LOCK-PREREQ): not yet awarded, station level in
// range, and every prerequisite group already awarded.
std::vector<const UnlockGroupDef*> pool;
for (const UnlockGroupDef& group : m_config.unlocks.groups)
{
if (def.unlockAtStationLevel < 0 || def.unlockAtStationLevel > destroyedStationLevel) { continue; }
if (m_schematicLevels.at(def.id).unlocked) { continue; }
if (!prerequisitesSatisfied(def.unlockRequires)) { continue; }
pool.push_back({def.id, DropType::Ship});
}
for (const ModuleDef& def : m_config.modules.modules)
{
if (def.unlockAtStationLevel < 0 || def.unlockAtStationLevel > destroyedStationLevel) { continue; }
if (m_moduleSchematicLevels.at(def.id).unlocked) { continue; }
if (!prerequisitesSatisfied(def.unlockRequires)) { continue; }
pool.push_back({def.id, DropType::Module});
}
for (const RecipeDef& def : m_config.recipes.recipes)
{
if (def.building != BuildingType::Assembler) { continue; }
if (!def.unlockAtStationLevel.has_value()) { continue; }
const int level = def.unlockAtStationLevel.value();
if (level < 0 || level > destroyedStationLevel) { continue; }
if (m_unlockedRecipeSchematicIds.count(def.id) > 0) { continue; }
bool outputUnlocked = false;
for (const RecipeOutput& out : def.outputs)
{
if (m_unlockedItemIds.count(out.item) > 0) { outputUnlocked = true; break; }
}
if (!outputUnlocked) { continue; }
if (!prerequisitesSatisfied(def.unlockRequires)) { continue; }
pool.push_back({def.id, DropType::Recipe});
if (m_awardedUnlockGroupIds.count(group.id) > 0) { continue; }
if (group.stationLevel < 0 || group.stationLevel > destroyedStationLevel) { continue; }
if (!prerequisitesSatisfied(group.requiredGroupIds)) { continue; }
pool.push_back(&group);
}
if (pool.empty()) { return; }
@@ -679,9 +647,6 @@ void Simulation::generateSchematicChoices(int destroyedStationLevel)
const int numChoices = std::min(static_cast<int>(pool.size()), artifactRolled ? 2 : 3);
m_pendingSchematicChoices.clear();
const std::set<std::string> currentShipIds = getUnlockedShipSchematicIds();
const std::set<std::string> currentModuleIds = getUnlockedModuleSchematicIds();
for (int i = 0; i < numChoices; ++i)
{
std::uniform_int_distribution<int> dist(0, static_cast<int>(pool.size()) - 1 - i);
@@ -689,68 +654,67 @@ void Simulation::generateSchematicChoices(int destroyedStationLevel)
const std::size_t rollIdx = static_cast<std::size_t>(roll);
const std::size_t endIdx = pool.size() - 1 - static_cast<std::size_t>(i);
std::swap(pool[rollIdx], pool[endIdx]);
const PoolEntry& entry = pool[endIdx];
SchematicChoiceOption option;
option.schematicId = entry.id;
if (entry.type == DropType::Ship)
{
option.type = SchematicType::Ship;
option.displayName = toDisplayName(entry.id);
}
else if (entry.type == DropType::Module)
{
option.type = SchematicType::Module;
option.displayName = toDisplayName(entry.id);
}
else
{
option.type = SchematicType::Recipe;
option.displayName = toDisplayName(entry.id);
}
// REQ-DEF-SCHEMATIC-DROP: preview recipes newly implicitly unlocked by this option.
std::set<std::string> hypotheticalShipIds = currentShipIds;
std::set<std::string> hypotheticalModuleIds = currentModuleIds;
std::set<std::string> hypotheticalRecipeSchematicIds = m_unlockedRecipeSchematicIds;
if (entry.type == DropType::Ship)
{
hypotheticalShipIds.insert(entry.id);
}
else if (entry.type == DropType::Module)
{
hypotheticalModuleIds.insert(entry.id);
}
else if (entry.type == DropType::Recipe)
{
hypotheticalRecipeSchematicIds.insert(entry.id);
}
const UnlockedSets hypothetical = computeUnlockedSets(
hypotheticalShipIds, hypotheticalModuleIds, hypotheticalRecipeSchematicIds);
option.newlyUnlockedRecipeIds = computeNewlyUnlockedRecipeIds(hypothetical);
m_pendingSchematicChoices.push_back(option);
m_pendingSchematicChoices.push_back(makeUnlockOption(*pool[endIdx]));
}
if (artifactRolled)
{
SchematicChoiceOption artifactOption;
artifactOption.schematicId = "";
artifactOption.type = SchematicType::Artifact;
artifactOption.displayName = "Artifact";
artifactOption.isArtifact = true;
artifactOption.displayName = "Artifact";
m_pendingSchematicChoices.push_back(std::move(artifactOption));
}
}
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<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 Simulation::applySchematicChoice(int choiceIndex)
{
assert(choiceIndex >= 0 && choiceIndex < static_cast<int>(m_pendingSchematicChoices.size()));
const SchematicChoiceOption& chosen = m_pendingSchematicChoices[static_cast<std::size_t>(choiceIndex)];
if (chosen.type == SchematicType::Artifact)
if (chosen.isArtifact)
{
m_artifactCount += 1;
if (m_artifactCount >= m_config.world.artifacts.artifactWinCount)
@@ -761,16 +725,18 @@ void Simulation::applySchematicChoice(int choiceIndex)
return;
}
if (chosen.type == SchematicType::Recipe)
// 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)
{
m_unlockedRecipeSchematicIds.insert(chosen.schematicId);
}
else
{
SchematicState& state = (chosen.type == SchematicType::Module)
? m_moduleSchematicLevels.at(chosen.schematicId)
: m_schematicLevels.at(chosen.schematicId);
state.unlocked = true;
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();
@@ -809,28 +775,13 @@ std::set<std::string> Simulation::getUnlockedModuleSchematicIds() const
return ids;
}
bool Simulation::isSchematicIdExplicitlyUnlocked(const std::string& schematicId) const
bool Simulation::prerequisitesSatisfied(const std::vector<std::string>& requiredGroupIds) const
{
// A prerequisite is satisfied only by an explicitly-unlocked schematic
// (REQ-LOCK-PREREQ): a ship or module schematic marked unlocked, or a recipe
// schematic in the explicit-unlock set. Cross-type ids are looked up with
// find/count so an id from one namespace never throws against another.
const std::map<std::string, SchematicState>::const_iterator shipIt =
m_schematicLevels.find(schematicId);
if (shipIt != m_schematicLevels.end() && shipIt->second.unlocked) { return true; }
const std::map<std::string, SchematicState>::const_iterator moduleIt =
m_moduleSchematicLevels.find(schematicId);
if (moduleIt != m_moduleSchematicLevels.end() && moduleIt->second.unlocked) { return true; }
return m_unlockedRecipeSchematicIds.count(schematicId) > 0;
}
bool Simulation::prerequisitesSatisfied(const std::vector<std::string>& unlockRequires) const
{
for (const std::string& requiredId : unlockRequires)
// A prerequisite is satisfied only once the named unlock group has been
// awarded (REQ-LOCK-PREREQ).
for (const std::string& groupId : requiredGroupIds)
{
if (!isSchematicIdExplicitlyUnlocked(requiredId)) { return false; }
if (m_awardedUnlockGroupIds.count(groupId) == 0) { return false; }
}
return true;
}
@@ -860,9 +811,11 @@ Simulation::UnlockedSets Simulation::computeUnlockedSets(
}
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.unlockAtStationLevel.has_value()
&& unlockedRecipeSchematicIds.count(def.id) > 0)
&& (def.unlockedAtStart || unlockedRecipeSchematicIds.count(def.id) > 0))
{
for (const RecipeOutput& out : def.outputs)
{
@@ -883,8 +836,10 @@ Simulation::UnlockedSets Simulation::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
&& recipe.unlockAtStationLevel.has_value()
&& m_grantedRecipeIds.count(recipe.id) > 0
&& unlockedRecipeSchematicIds.count(recipe.id) == 0)
{
continue;
@@ -1000,6 +955,8 @@ unsigned long long Simulation::computeStateChecksum() const
// 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);
@@ -1193,8 +1150,28 @@ bool Simulation::isModuleSchematicUnlocked(const std::string& moduleId) const
return it->second.unlocked;
}
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<std::string, SchematicState>::const_iterator it =
m_buildingLevels.find(def->id);
return it == m_buildingLevels.end() ? true : it->second.unlocked;
}
std::optional<BuildingId> Simulation::tryPlaceBuilding(BuildingType type, QPoint anchor, Rotation rotation)
{
// Locked building types cannot be placed (REQ-LOCK-BUILDING); the build menu
// hides them, this is the simulation-side backstop (e.g. blueprint placement).
if (!isBuildingUnlocked(type))
{
return std::nullopt;
}
if (!m_buildingSystem->isPlacementValid(type, anchor, rotation))
{
return std::nullopt;

View File

@@ -100,6 +100,10 @@ public:
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;
// -- Determinism (see docs/replay_design.md) -----------------------------
// 64-bit fingerprint of the RNG stream state. Cheap; written to the replay
// file periodically + after each command for desync detection.
@@ -196,6 +200,24 @@ private:
};
std::map<std::string, SchematicState> m_schematicLevels;
std::map<std::string, SchematicState> m_moduleSchematicLevels;
std::map<std::string, SchematicState> m_buildingLevels;
// Unlock groups awarded so far (REQ-LOCK-EXPLICIT). Group ids.
std::set<std::string> 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<std::string> m_grantedShipIds;
std::set<std::string> m_grantedModuleIds;
std::set<std::string> m_grantedBuildingIds;
std::set<std::string> 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,
@@ -228,11 +250,8 @@ private:
std::set<std::string> getUnlockedShipSchematicIds() const;
std::set<std::string> getUnlockedModuleSchematicIds() const;
// True if schematicId names a currently explicitly-unlocked ship, module, or
// assembler recipe schematic (REQ-LOCK-PREREQ satisfaction basis).
bool isSchematicIdExplicitlyUnlocked(const std::string& schematicId) const;
// True if every prerequisite in unlockRequires is explicitly unlocked (REQ-LOCK-PREREQ).
bool prerequisitesSatisfied(const std::vector<std::string>& unlockRequires) const;
// True if every prerequisite unlock group has been awarded (REQ-LOCK-PREREQ).
bool prerequisitesSatisfied(const std::vector<std::string>& requiredGroupIds) const;
// Ids (sorted alphabetically by display name) of the recipes in
// hypothetical.recipeIds that are not yet in m_unlockedRecipeIds.