Implement config-driven unlock groups so that multiple things can be unlocked at once (including buildings)
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
26
src/lib/config/UnlocksConfig.h
Normal file
26
src/lib/config/UnlocksConfig.h
Normal 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;
|
||||
};
|
||||
Reference in New Issue
Block a user