implement unlock dependencies
This commit is contained in:
@@ -4,6 +4,11 @@
|
||||
# content; stats, materials, and threat costs are placeholders until the
|
||||
# recipe and balancing passes.
|
||||
#
|
||||
# Unlock progression is mostly disabled (unlock_at_station_level = -1) pending
|
||||
# the balancing pass. The laser cannons are the exception: laser_cannon_m is
|
||||
# gated to station level 1, and laser_cannon_l requires laser_cannon_m to be
|
||||
# unlocked first (unlock_requires) — a demonstration of the prerequisite chain.
|
||||
#
|
||||
# Surface mask footprint ladder — footprints gate which hulls can mount a
|
||||
# module, purely through geometry (see ships.toml for the matching hull
|
||||
# grids):
|
||||
@@ -39,7 +44,7 @@ attack_rate_hz = 2.0
|
||||
|
||||
[[module]]
|
||||
id = "laser_cannon_m"
|
||||
unlock_at_station_level = -1
|
||||
unlock_at_station_level = 1
|
||||
surface_mask = [
|
||||
"OO",
|
||||
"OO"]
|
||||
@@ -56,7 +61,8 @@ attack_rate_hz = 1.5
|
||||
|
||||
[[module]]
|
||||
id = "laser_cannon_l"
|
||||
unlock_at_station_level = -1
|
||||
unlock_at_station_level = 1
|
||||
unlock_requires = ["laser_cannon_m"]
|
||||
surface_mask = [
|
||||
"OOO",
|
||||
"OOO",
|
||||
|
||||
@@ -2,8 +2,10 @@
|
||||
#
|
||||
# First real-content iteration of the production tree. Quantities and
|
||||
# durations are a first guess; the balancing pass will tune them and assign
|
||||
# real unlock_at_station_level values (everything is unlocked for now so the
|
||||
# full tree is testable).
|
||||
# real unlock_at_station_level values. Almost everything is unlocked for now so
|
||||
# the full tree is testable; the only gated recipe schematics are the medium and
|
||||
# large laser-cannon item recipes, which demonstrate the unlock_requires
|
||||
# prerequisite chain (large requires medium — see modules.toml / ships.toml).
|
||||
#
|
||||
# Input chain per game phase — each phase adds exactly one new base input:
|
||||
#
|
||||
@@ -293,7 +295,7 @@ duration_seconds = 4.0
|
||||
|
||||
[[recipe]]
|
||||
id = "laser_cannon_m_module"
|
||||
unlock_at_station_level = -1
|
||||
unlock_at_station_level = 1
|
||||
building = "assembler"
|
||||
inputs = [{item = "targeting_unit", amount = 1}, {item = "titanium_frame", amount = 1}]
|
||||
outputs = [{item = "laser_cannon_m_module", amount = 1}]
|
||||
@@ -317,7 +319,8 @@ duration_seconds = 6.0
|
||||
|
||||
[[recipe]]
|
||||
id = "laser_cannon_l_module"
|
||||
unlock_at_station_level = -1
|
||||
unlock_at_station_level = 1
|
||||
unlock_requires = ["laser_cannon_m_module"]
|
||||
building = "assembler"
|
||||
inputs = [
|
||||
{item = "targeting_unit", amount = 2},
|
||||
|
||||
@@ -4,6 +4,11 @@
|
||||
# content; stats, materials, and production times are placeholders until the
|
||||
# recipe and balancing passes.
|
||||
#
|
||||
# Unlock progression is mostly disabled (unlock_at_station_level = -1) pending
|
||||
# the balancing pass. The capital hulls are the exception: battleship is gated
|
||||
# to station level 1, and dreadnought (level 2) requires battleship to be
|
||||
# unlocked first (unlock_requires) — a demonstration of the prerequisite chain.
|
||||
#
|
||||
# Size classes:
|
||||
# xs drone 1 cell — exactly one 1x1 module
|
||||
# s frigate, destroyer no 2x2 area anywhere: only 1x1/1x2/1x3/L modules fit
|
||||
@@ -169,7 +174,7 @@ sensor_range_m = 260
|
||||
# so no 2x6 drone hangar fits.
|
||||
[[ship]]
|
||||
id = "battleship"
|
||||
unlock_at_station_level = -1
|
||||
unlock_at_station_level = 1
|
||||
layout = [
|
||||
"XOOOOX",
|
||||
"OOOOOO",
|
||||
@@ -203,7 +208,8 @@ sensor_range_m = 280
|
||||
# stay the only hangar hull. Bow and stern strips hold supports.
|
||||
[[ship]]
|
||||
id = "dreadnought"
|
||||
unlock_at_station_level = -1
|
||||
unlock_at_station_level = 2
|
||||
unlock_requires = ["battleship"]
|
||||
layout = [
|
||||
"XXXOOOOOXXX",
|
||||
"OOOXOOOXOOO",
|
||||
|
||||
@@ -6,9 +6,9 @@ Config files use the TOML format. The following config files drive game paramete
|
||||
|
||||
- **world.toml** — world dimensions, region widths, expansion amounts, building refund percentage, wave timing, boss wave timing, belt speed, starting building blocks, departure interval, ship orbit factor, rally orbit radius, scrap-per-threat conversion, combat target-selection parameters (target score formula, overclaim penalty formula, target hysteresis), artifact chance formula, and artifact win count.
|
||||
- **buildings.toml** — building block cost and construction time per building type.
|
||||
- **recipes.toml** — crafting recipes: inputs, outputs, quantities, durations, and reprocessing plant probabilities. Assembler recipe entries may optionally define `unlock_at_station_level` (integer): -1 means the recipe is explicitly unlocked at game start; a value ≥ 0 means the recipe starts locked and a schematic for it can be awarded via defence station destruction (see REQ-LOCK-EXPLICIT, REQ-DEF-SCHEMATIC-DROP).
|
||||
- **ships.toml** — per schematic: a human-readable display name (used in the UI), hull stats (HP, max linear speed, sensor range, main acceleration, maneuvering acceleration, angular acceleration, max rotation speed) as plain values, required build materials, the station level at which the schematic becomes available for unlock (`unlock_at_station_level`; -1 means the player starts with the schematic already unlocked), a layout grid defining the ship's module slots, and a `default_modules` list used for enemy wave ships (see REQ-WAV-DEFAULT-MODULES).
|
||||
- **modules.toml** — per module type: id, surface mask, materials list, production time, fill color, glyph, the station level at which the schematic becomes available for unlock (`unlock_at_station_level`; -1 means the player starts with the module schematic already unlocked), and an optional capability section and/or stat modifier formulas. A module with a capability section (`[module.weapon]`, `[module.salvage]`, or `[module.repair]`) containing base stat formulas is a **capability module** that grants the ship a weapon, salvage bay, or repair tool per instance (see REQ-MOD-CONFIG for the full list of formulas per capability type). A module with only `added_*`/`multiplied_*` formulas is a **passive module** that modifies stats on the ship or on capability module instances (see REQ-MOD-STAT-CALC).
|
||||
- **recipes.toml** — crafting recipes: inputs, outputs, quantities, durations, and reprocessing plant probabilities. Assembler recipe entries may optionally define `unlock_at_station_level` (integer): -1 means the recipe is explicitly unlocked at game start; a value ≥ 0 means the recipe starts locked and a schematic for it can be awarded via defence station destruction (see REQ-LOCK-EXPLICIT, REQ-DEF-SCHEMATIC-DROP). An assembler recipe schematic entry may also define an optional `unlock_requires` list of prerequisite schematic ids (REQ-LOCK-PREREQ).
|
||||
- **ships.toml** — per schematic: a human-readable display name (used in the UI), hull stats (HP, max linear speed, sensor range, main acceleration, maneuvering acceleration, angular acceleration, max rotation speed) as plain values, required build materials, the station level at which the schematic becomes available for unlock (`unlock_at_station_level`; -1 means the player starts with the schematic already unlocked), an optional `unlock_requires` prerequisite list (REQ-LOCK-PREREQ), a layout grid defining the ship's module slots, and a `default_modules` list used for enemy wave ships (see REQ-WAV-DEFAULT-MODULES).
|
||||
- **modules.toml** — per module type: id, surface mask, materials list, production time, fill color, glyph, the station level at which the schematic becomes available for unlock (`unlock_at_station_level`; -1 means the player starts with the module schematic already unlocked), an optional `unlock_requires` prerequisite list (REQ-LOCK-PREREQ), and an optional capability section and/or stat modifier formulas. A module with a capability section (`[module.weapon]`, `[module.salvage]`, or `[module.repair]`) containing base stat formulas is a **capability module** that grants the ship a weapon, salvage bay, or repair tool per instance (see REQ-MOD-CONFIG for the full list of formulas per capability type). A module with only `added_*`/`multiplied_*` formulas is a **passive module** that modifies stats on the ship or on capability module instances (see REQ-MOD-STAT-CALC).
|
||||
- **stations.toml** — HP, damage, range, fire rate, and scrap drop for player and enemy defence stations, defined as formulas of station level.
|
||||
- **visuals.toml** — rendering-only config (not game parameters): fill and outline colors and glyphs for every building type, item type, ship schematic, and station type; a distinct beam color per tool type (weapon, repair, salvage) and beam width; overlay and toast colors. Loaded by the UI at startup; the simulation does not read it.
|
||||
- **ship_layouts.toml** — named layout blueprints per ship type; written and read by the application to persist the layout blueprint panel (REQ-MOD-UI-BLUEPRINT-PANEL through REQ-MOD-UI-BLUEPRINT-FILE-LOAD). Not a game parameter file; the simulation does not read it.
|
||||
@@ -197,6 +197,7 @@ Modules in `modules.toml` define a `surface_mask` — a list of strings that des
|
||||
- `surface_mask` — footprint within the ship layout grid (see Module Surface Mask Format).
|
||||
- `materials` — list of materials required per instance (added to the ship's build cost).
|
||||
- `unlock_at_station_level` — the enemy defence station level at which this module's schematic becomes available for unlock; -1 means the player starts with the module schematic already unlocked.
|
||||
- `unlock_requires` — optional list of prerequisite schematic ids that must already be unlocked before this module's schematic can drop (REQ-LOCK-PREREQ). Defaults to empty.
|
||||
- `production_time_seconds` — time added to the ship's production cycle per instance.
|
||||
- `fill_color` — fill color used to render this module's cells in the layout grid.
|
||||
- `glyph` — single character rendered on this module's cells in the layout grid and preview widget.
|
||||
@@ -307,6 +308,8 @@ Modules in `modules.toml` define a `surface_mask` — a list of strings that des
|
||||
- All **ship schematics** and **module schematics** whose `unlock_at_station_level` is ≥ 0 and ≤ the level of the destroyed station set, and which have not yet been unlocked.
|
||||
- All **assembler recipe schematics** whose `unlock_at_station_level` is ≥ 0 and ≤ the level of the destroyed station set, whose output item is currently implicitly unlocked (REQ-LOCK-IMPLICIT), and which have not yet been awarded.
|
||||
|
||||
In addition to the conditions above, a schematic is included in the eligible drop pool only when every prerequisite in its optional `unlock_requires` list is currently satisfied (REQ-LOCK-PREREQ). Because the pool is rebuilt for each drop, a schematic gated behind prerequisites first appears only after all of its prerequisites have themselves been unlocked.
|
||||
|
||||
Each option in the dialog displays: the schematic name (ship `display_name` from `ships.toml`, module `id` from `modules.toml`, or the output item type for assembler recipes) and the schematic type (ship, module, or assembler recipe). The artifact option (if present) is displayed as a distinct entry with the name "Artifact".
|
||||
|
||||
Each option additionally displays a vertical list of item names labeled "Unlocks recipes for:", showing which recipes would newly become implicitly unlocked (REQ-LOCK-IMPLICIT) if this option were selected — specifically, the output items of miner recipes and assembler recipes (without `unlock_at_station_level`) that are not currently implicitly unlocked but would become so after applying this option's effect:
|
||||
@@ -325,6 +328,8 @@ Modules in `modules.toml` define a `surface_mask` — a list of strings that des
|
||||
|
||||
- REQ-LOCK-EXPLICIT: Ship schematics, module schematics, and **assembler recipe schematics** (assembler recipes in `recipes.toml` that define `unlock_at_station_level`) are **explicitly** locked or unlocked. A schematic starts unlocked if its `unlock_at_station_level` is -1; all others start locked. Locked schematics are unlocked only by REQ-DEF-SCHEMATIC-DROP. Once unlocked, a schematic is never re-locked within a run; lock states reset to their initial values on Restart (REQ-CFG-RELOAD). Unlike ship and module schematics, an assembler recipe schematic is removed from the drop pool permanently once awarded and cannot be dropped again.
|
||||
|
||||
- REQ-LOCK-PREREQ: A ship schematic, module schematic, or assembler recipe schematic may optionally define `unlock_requires` — a list of prerequisite schematic ids (a ship `id`, module `id`, or assembler recipe `id`) that must already be unlocked before this schematic may enter the drop pool. A prerequisite is **satisfied** only when the schematic it names is currently **explicitly unlocked** (REQ-LOCK-EXPLICIT) — that is, the referenced schematic either started unlocked with `unlock_at_station_level = -1` or has been awarded via a drop. This prerequisite check is applied in addition to the per-schematic conditions in REQ-DEF-SCHEMATIC-DROP: a schematic enters the eligible drop pool only when its `unlock_at_station_level` condition is met, it has not yet been unlocked/awarded, and every id in its `unlock_requires` is satisfied. `unlock_requires` defaults to empty (no prerequisites), which reproduces the prior behaviour. The check is re-evaluated against the current explicit-unlock set every time a drop pool is built (after each REQ-DEF-SCHEMATIC-DROP and on Restart per REQ-CFG-RELOAD), so a gated schematic becomes eligible in the first drop after its last prerequisite is unlocked. Every id listed in any `unlock_requires` must resolve to a schematic that is itself explicitly unlockable (a ship, module, or assembler recipe schematic defined in config); an id that names no such schematic is a configuration error that fails config load with a descriptive message (config is loaded at startup and reloaded on Restart, REQ-CFG-RELOAD). A schematic that lists itself, or a cycle of mutually dependent prerequisites, is not a load error but can never become eligible, since no schematic in the cycle can be the first to unlock.
|
||||
|
||||
- REQ-LOCK-IMPLICIT: Item types and miner/assembler recipes are **implicitly** unlocked or locked based on the current set of unlocked ship, module, and assembler recipe schematics. The implicit unlock set is recomputed whenever any schematic changes lock state (on Restart or after REQ-DEF-SCHEMATIC-DROP). Computation:
|
||||
1. Start with the union of: (a) all item types listed in `materials` across all currently unlocked ship schematics and all currently unlocked module schematics, and (b) the output item type of every currently explicitly unlocked assembler recipe schematic (REQ-LOCK-EXPLICIT).
|
||||
2. For each item type in the current set: for every recipe (miner, smelter, or assembler) that produces it — skipping any assembler recipe schematic that defines `unlock_at_station_level` and is not yet explicitly unlocked — add each of that recipe's input item types to the set. If the recipe is a miner recipe or an assembler recipe that does not define `unlock_at_station_level`, mark it as implicitly unlocked. Explicitly unlocked assembler recipe schematics are available in the assembler recipe-selection dialog by virtue of REQ-LOCK-EXPLICIT; their inputs are also added to the implicit set in this step.
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
#include <sstream>
|
||||
#include <stdexcept>
|
||||
#include <string>
|
||||
#include <unordered_set>
|
||||
#include <utility>
|
||||
#include <vector>
|
||||
|
||||
@@ -383,6 +384,11 @@ RecipesConfig ConfigLoader::loadRecipes(const std::string& path)
|
||||
{
|
||||
def.unlockAtStationLevel = static_cast<int>(*level);
|
||||
}
|
||||
if (mt.contains("unlock_requires"))
|
||||
{
|
||||
def.unlockRequires = requireStringArray(mt["unlock_requires"], file,
|
||||
elemPath + ".unlock_requires");
|
||||
}
|
||||
}
|
||||
|
||||
// inputs may be omitted (e.g. miner recipes). An empty array is fine.
|
||||
@@ -422,6 +428,10 @@ 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
|
||||
@@ -573,6 +583,10 @@ ModulesConfig ConfigLoader::loadModules(const std::string& path)
|
||||
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");
|
||||
@@ -685,6 +699,67 @@ ModulesConfig ConfigLoader::loadModules(const std::string& path)
|
||||
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)
|
||||
{
|
||||
for (const std::string& requiredId : requiredIds)
|
||||
{
|
||||
if (schematicIds.count(requiredId) == 0)
|
||||
{
|
||||
throw makeError(file, path,
|
||||
"references unknown schematic '" + requiredId + "'");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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);
|
||||
}
|
||||
for (const RecipeDef& def : cfg.recipes.recipes)
|
||||
{
|
||||
if (def.building == BuildingType::Assembler && def.unlockAtStationLevel.has_value())
|
||||
{
|
||||
schematicIds.insert(def.id);
|
||||
}
|
||||
}
|
||||
|
||||
for (const ShipDef& def : cfg.ships.ships)
|
||||
{
|
||||
checkUnlockRequires(def.unlockRequires, schematicIds, "ships.toml",
|
||||
"ship '" + def.id + "'.unlock_requires");
|
||||
}
|
||||
for (const ModuleDef& def : cfg.modules.modules)
|
||||
{
|
||||
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");
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
GameConfig ConfigLoader::loadFromDirectory(const std::string& configDir)
|
||||
{
|
||||
GameConfig cfg;
|
||||
@@ -694,6 +769,7 @@ 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.threatCosts = computeThreatCostTable(cfg);
|
||||
return cfg;
|
||||
}
|
||||
|
||||
@@ -41,6 +41,9 @@ 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;
|
||||
|
||||
@@ -36,6 +36,10 @@ struct RecipeDef
|
||||
// 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;
|
||||
};
|
||||
|
||||
struct RecipesConfig
|
||||
|
||||
@@ -36,6 +36,9 @@ 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;
|
||||
|
||||
@@ -635,12 +635,14 @@ void Simulation::generateSchematicChoices(int destroyedStationLevel)
|
||||
{
|
||||
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)
|
||||
@@ -656,6 +658,7 @@ void Simulation::generateSchematicChoices(int destroyedStationLevel)
|
||||
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});
|
||||
}
|
||||
|
||||
@@ -808,6 +811,32 @@ std::set<std::string> Simulation::getUnlockedModuleSchematicIds() const
|
||||
return ids;
|
||||
}
|
||||
|
||||
bool Simulation::isSchematicIdExplicitlyUnlocked(const std::string& schematicId) 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)
|
||||
{
|
||||
if (!isSchematicIdExplicitlyUnlocked(requiredId)) { return false; }
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
Simulation::UnlockedSets Simulation::computeUnlockedSets(
|
||||
const std::set<std::string>& unlockedShipSchematicIds,
|
||||
const std::set<std::string>& unlockedModuleSchematicIds,
|
||||
|
||||
@@ -215,6 +215,12 @@ 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;
|
||||
|
||||
// Display names (deduplicated, alphabetical) of output items of recipes in
|
||||
// hypothetical.recipeIds that are not yet in m_unlockedRecipeIds.
|
||||
std::vector<std::string> computeNewlyUnlockedItemNames(const UnlockedSets& hypothetical) const;
|
||||
|
||||
@@ -21,6 +21,7 @@ add_files(
|
||||
ShipModuleTest.cpp
|
||||
ThreatCostCalculatorTest.cpp
|
||||
RecipeSchematicTest.cpp
|
||||
UnlockPrereqTest.cpp
|
||||
ArtifactWinConditionTest.cpp
|
||||
DeterminismTest.cpp
|
||||
CommandTest.cpp
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
#include <stdexcept>
|
||||
#include <string>
|
||||
#include <system_error>
|
||||
#include <vector>
|
||||
|
||||
#include "BuildingType.h"
|
||||
#include "ConfigLoader.h"
|
||||
@@ -343,3 +344,151 @@ duration_seconds = 1.0
|
||||
std::runtime_error);
|
||||
}
|
||||
|
||||
// --- unlock_requires (REQ-LOCK-PREREQ) ------------------------------------
|
||||
|
||||
namespace
|
||||
{
|
||||
|
||||
// Copies every regular file from CONFIG_DIR into dir, giving tests a full,
|
||||
// valid config set they can then perturb before calling loadFromDirectory.
|
||||
void copyConfigInto(const std::filesystem::path& dir)
|
||||
{
|
||||
for (const std::filesystem::directory_entry& entry :
|
||||
std::filesystem::directory_iterator(std::string(CONFIG_DIR)))
|
||||
{
|
||||
if (entry.is_regular_file())
|
||||
{
|
||||
std::filesystem::copy_file(entry.path(), dir / entry.path().filename());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
TEST_CASE("unlock_requires parses into a ship def", "[config]")
|
||||
{
|
||||
TempConfigDir dir;
|
||||
writeFile(dir.path() / "ships.toml", R"(
|
||||
[[ship]]
|
||||
id = "alpha"
|
||||
unlock_at_station_level = 1
|
||||
unlock_requires = ["beta", "gamma"]
|
||||
layout = ["O"]
|
||||
[ship.schematic]
|
||||
materials = []
|
||||
production_time_seconds = 5
|
||||
[ship.health]
|
||||
hp = 10
|
||||
[ship.movement]
|
||||
speed_mps = 1
|
||||
main_acceleration_mpss = 1
|
||||
maneuvering_acceleration_mpss = 1
|
||||
angular_acceleration_radpss = 1
|
||||
max_rotation_speed_radps = 1
|
||||
[ship.sensor]
|
||||
sensor_range_m = 10
|
||||
)");
|
||||
|
||||
const ShipsConfig cfg = ConfigLoader::loadShips((dir.path() / "ships.toml").string());
|
||||
REQUIRE(cfg.ships.size() == 1);
|
||||
CHECK(cfg.ships[0].unlockRequires == std::vector<std::string>{"beta", "gamma"});
|
||||
}
|
||||
|
||||
TEST_CASE("unlock_requires parses into a module def", "[config]")
|
||||
{
|
||||
TempConfigDir dir;
|
||||
writeFile(dir.path() / "modules.toml", R"(
|
||||
[[module]]
|
||||
id = "m1"
|
||||
unlock_at_station_level = 0
|
||||
unlock_requires = ["dep"]
|
||||
surface_mask = ["O"]
|
||||
materials = [{item = "iron_ingot", amount = 1}]
|
||||
production_time_seconds = 1
|
||||
fill_color = "#ffffff"
|
||||
glyph = "M"
|
||||
)");
|
||||
|
||||
const ModulesConfig cfg = ConfigLoader::loadModules((dir.path() / "modules.toml").string());
|
||||
REQUIRE(cfg.modules.size() == 1);
|
||||
CHECK(cfg.modules[0].unlockRequires == std::vector<std::string>{"dep"});
|
||||
}
|
||||
|
||||
TEST_CASE("unlock_requires parses only for assembler recipes", "[config]")
|
||||
{
|
||||
TempConfigDir dir;
|
||||
writeFile(dir.path() / "recipes.toml", R"(
|
||||
[[recipe]]
|
||||
id = "gated_asm"
|
||||
building = "assembler"
|
||||
unlock_at_station_level = 1
|
||||
unlock_requires = ["prereq_recipe"]
|
||||
inputs = []
|
||||
outputs = [{item = "foo", amount = 1}]
|
||||
duration_seconds = 1.0
|
||||
|
||||
[[recipe]]
|
||||
id = "a_miner"
|
||||
building = "miner"
|
||||
unlock_requires = ["ignored"]
|
||||
inputs = []
|
||||
outputs = [{item = "ore", amount = 1}]
|
||||
duration_seconds = 1.0
|
||||
)");
|
||||
|
||||
const RecipesConfig cfg = ConfigLoader::loadRecipes((dir.path() / "recipes.toml").string());
|
||||
REQUIRE(cfg.recipes.size() == 2);
|
||||
CHECK(cfg.recipes[0].unlockRequires == std::vector<std::string>{"prereq_recipe"});
|
||||
// The field is only consumed for assembler recipe schematics; a miner recipe
|
||||
// leaves it unparsed (empty).
|
||||
CHECK(cfg.recipes[1].unlockRequires.empty());
|
||||
}
|
||||
|
||||
TEST_CASE("unlock_requires referencing an unknown schematic is rejected at load", "[config]")
|
||||
{
|
||||
TempConfigDir dir;
|
||||
copyConfigInto(dir.path());
|
||||
|
||||
std::ofstream out((dir.path() / "recipes.toml").string(), std::ios::app);
|
||||
out << "\n[[recipe]]\n"
|
||||
"id = \"gated_bogus\"\n"
|
||||
"building = \"assembler\"\n"
|
||||
"unlock_at_station_level = 1\n"
|
||||
"unlock_requires = [\"___does_not_exist___\"]\n"
|
||||
"inputs = []\n"
|
||||
"outputs = [{item = \"circuit_board\", amount = 1}]\n"
|
||||
"duration_seconds = 1.0\n";
|
||||
out.close();
|
||||
|
||||
try
|
||||
{
|
||||
ConfigLoader::loadFromDirectory(dir.path().string());
|
||||
FAIL("Expected exception");
|
||||
}
|
||||
catch (const std::runtime_error& e)
|
||||
{
|
||||
const std::string msg = e.what();
|
||||
REQUIRE(msg.find("___does_not_exist___") != std::string::npos);
|
||||
}
|
||||
}
|
||||
|
||||
TEST_CASE("unlock_requires referencing a real schematic loads without error", "[config]")
|
||||
{
|
||||
TempConfigDir dir;
|
||||
copyConfigInto(dir.path());
|
||||
|
||||
// "interceptor" is a ship schematic in the test config — a valid prerequisite.
|
||||
std::ofstream out((dir.path() / "recipes.toml").string(), std::ios::app);
|
||||
out << "\n[[recipe]]\n"
|
||||
"id = \"gated_ok\"\n"
|
||||
"building = \"assembler\"\n"
|
||||
"unlock_at_station_level = 1\n"
|
||||
"unlock_requires = [\"interceptor\"]\n"
|
||||
"inputs = []\n"
|
||||
"outputs = [{item = \"circuit_board\", amount = 1}]\n"
|
||||
"duration_seconds = 1.0\n";
|
||||
out.close();
|
||||
|
||||
REQUIRE_NOTHROW(ConfigLoader::loadFromDirectory(dir.path().string()));
|
||||
}
|
||||
|
||||
|
||||
174
src/test/UnlockPrereqTest.cpp
Normal file
174
src/test/UnlockPrereqTest.cpp
Normal file
@@ -0,0 +1,174 @@
|
||||
// Drop-pool behaviour for unlock_requires schematic prerequisites
|
||||
// (REQ-LOCK-PREREQ). A schematic enters the drop pool only when every id in its
|
||||
// unlock_requires is already explicitly unlocked, on top of the station-level
|
||||
// (and, for recipes, output-implicitly-unlocked) checks.
|
||||
//
|
||||
// Configs are built in memory (load the test config, then mutate) so each case
|
||||
// is isolated and does not perturb the shared test config used by other suites.
|
||||
|
||||
#include <algorithm>
|
||||
#include <string>
|
||||
#include <utility>
|
||||
#include <vector>
|
||||
|
||||
#include "catch.hpp"
|
||||
|
||||
#include "ConfigLoader.h"
|
||||
#include "FactionComponent.h"
|
||||
#include "GameConfig.h"
|
||||
#include "HealthComponent.h"
|
||||
#include "ModulesConfig.h"
|
||||
#include "RecipesConfig.h"
|
||||
#include "SchematicChoiceOption.h"
|
||||
#include "ShipsConfig.h"
|
||||
#include "Simulation.h"
|
||||
#include "SimulationTestAccess.h"
|
||||
#include "StationBodyComponent.h"
|
||||
|
||||
namespace
|
||||
{
|
||||
|
||||
GameConfig loadConfig()
|
||||
{
|
||||
return ConfigLoader::loadFromDirectory(CONFIG_DIR);
|
||||
}
|
||||
|
||||
ShipDef& findShip(GameConfig& cfg, const std::string& id)
|
||||
{
|
||||
for (ShipDef& def : cfg.ships.ships)
|
||||
{
|
||||
if (def.id == id) { return def; }
|
||||
}
|
||||
FAIL("ship not found: " + id);
|
||||
return cfg.ships.ships.front();
|
||||
}
|
||||
|
||||
ModuleDef& findModule(GameConfig& cfg, const std::string& id)
|
||||
{
|
||||
for (ModuleDef& def : cfg.modules.modules)
|
||||
{
|
||||
if (def.id == id) { return def; }
|
||||
}
|
||||
FAIL("module not found: " + id);
|
||||
return cfg.modules.modules.front();
|
||||
}
|
||||
|
||||
RecipeDef& findRecipe(GameConfig& cfg, const std::string& id)
|
||||
{
|
||||
for (RecipeDef& def : cfg.recipes.recipes)
|
||||
{
|
||||
if (def.id == id) { return def; }
|
||||
}
|
||||
FAIL("recipe not found: " + id);
|
||||
return cfg.recipes.recipes.front();
|
||||
}
|
||||
|
||||
// Zeros the HP of both enemy defence stations and advances one tick so that
|
||||
// tickDeathsAndLoot fires, triggering the push and schematic choices.
|
||||
void killEnemyStations(Simulation& sim)
|
||||
{
|
||||
sim.admin().forEach<StationBodyComponent, FactionComponent, HealthComponent>(
|
||||
[](entt::entity, StationBodyComponent&, FactionComponent& faction, HealthComponent& health)
|
||||
{
|
||||
if (faction.isEnemy)
|
||||
{
|
||||
health.hp = 0.0f;
|
||||
}
|
||||
});
|
||||
sim.tick();
|
||||
}
|
||||
|
||||
void killEnemyStationsAndApply(Simulation& sim)
|
||||
{
|
||||
killEnemyStations(sim);
|
||||
if (sim.hasSchematicChoicesPending())
|
||||
{
|
||||
SimulationTestAccess::applySchematicChoice(sim, 0);
|
||||
}
|
||||
}
|
||||
|
||||
bool optionOffered(const Simulation& sim, const std::string& id)
|
||||
{
|
||||
const std::vector<SchematicChoiceOption>& options = sim.getPendingSchematicChoices();
|
||||
return std::any_of(options.begin(), options.end(),
|
||||
[&](const SchematicChoiceOption& o) { return o.schematicId == id; });
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
TEST_CASE("UnlockPrereq: a recipe gated behind a locked ship is withheld from the pool",
|
||||
"[unlock_prereq]")
|
||||
{
|
||||
// quick_circuit (assembler recipe, level 0, output circuit_board is implicitly
|
||||
// unlocked) would normally be eligible at the first station destruction. Gate
|
||||
// it behind repair_ship (ship, level 0, locked at game start).
|
||||
GameConfig cfg = loadConfig();
|
||||
findRecipe(cfg, "quick_circuit").unlockRequires = {"repair_ship"};
|
||||
|
||||
Simulation sim(std::move(cfg), 123);
|
||||
REQUIRE_FALSE(sim.isSchematicUnlocked("repair_ship"));
|
||||
|
||||
killEnemyStations(sim); // destroyed set level 0
|
||||
REQUIRE(sim.hasSchematicChoicesPending());
|
||||
|
||||
// Prerequisite unmet -> the gated recipe must not be offered, while the
|
||||
// prerequisite schematic itself (the only other eligible pick) is.
|
||||
CHECK_FALSE(optionOffered(sim, "quick_circuit"));
|
||||
CHECK(optionOffered(sim, "repair_ship"));
|
||||
}
|
||||
|
||||
TEST_CASE("UnlockPrereq: the gated recipe becomes eligible once its prerequisite is unlocked",
|
||||
"[unlock_prereq]")
|
||||
{
|
||||
GameConfig cfg = loadConfig();
|
||||
findRecipe(cfg, "quick_circuit").unlockRequires = {"repair_ship"};
|
||||
|
||||
Simulation sim(std::move(cfg), 123);
|
||||
|
||||
// Applying choice 0 each drop unlocks repair_ship first (the only eligible
|
||||
// pick at level 0), which then opens quick_circuit for a later drop.
|
||||
bool unlocked = false;
|
||||
for (int i = 0; i < 150 && !unlocked; ++i)
|
||||
{
|
||||
killEnemyStationsAndApply(sim);
|
||||
unlocked = sim.isRecipeUnlocked("quick_circuit");
|
||||
}
|
||||
|
||||
CHECK(sim.isSchematicUnlocked("repair_ship")); // prerequisite got unlocked along the way
|
||||
CHECK(unlocked);
|
||||
}
|
||||
|
||||
TEST_CASE("UnlockPrereq: a module gated behind another module is withheld until it unlocks",
|
||||
"[unlock_prereq]")
|
||||
{
|
||||
// Mirror the app-config demo (laser_cannon_m -> laser_cannon_l) using two
|
||||
// test-config modules made lockable at the same station level.
|
||||
GameConfig cfg = loadConfig();
|
||||
findModule(cfg, "laser_cannon").unlockAtStationLevel = 0;
|
||||
ModuleDef& armor = findModule(cfg, "armor_plate");
|
||||
armor.unlockAtStationLevel = 0;
|
||||
armor.unlockRequires = {"laser_cannon"};
|
||||
|
||||
Simulation sim(std::move(cfg), 7);
|
||||
REQUIRE_FALSE(sim.isModuleSchematicUnlocked("laser_cannon"));
|
||||
REQUIRE_FALSE(sim.isModuleSchematicUnlocked("armor_plate"));
|
||||
|
||||
killEnemyStations(sim); // level 0
|
||||
REQUIRE(sim.hasSchematicChoicesPending());
|
||||
// armor_plate is gated behind the still-locked laser_cannon module.
|
||||
CHECK_FALSE(optionOffered(sim, "armor_plate"));
|
||||
|
||||
// Drive the drops until the gated module unlocks; it can only do so after its
|
||||
// prerequisite has itself been unlocked.
|
||||
bool armorUnlocked = false;
|
||||
for (int i = 0; i < 300 && !armorUnlocked; ++i)
|
||||
{
|
||||
killEnemyStationsAndApply(sim);
|
||||
armorUnlocked = sim.isModuleSchematicUnlocked("armor_plate");
|
||||
if (armorUnlocked)
|
||||
{
|
||||
CHECK(sim.isModuleSchematicUnlocked("laser_cannon"));
|
||||
}
|
||||
}
|
||||
CHECK(armorUnlocked);
|
||||
}
|
||||
Reference in New Issue
Block a user