implement unlock dependencies
This commit is contained in:
@@ -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