537 lines
17 KiB
C++
537 lines
17 KiB
C++
#include "catch.hpp"
|
|
|
|
#include <algorithm>
|
|
#include <filesystem>
|
|
#include <fstream>
|
|
#include <stdexcept>
|
|
#include <string>
|
|
#include <system_error>
|
|
#include <vector>
|
|
|
|
#include "BuildingType.h"
|
|
#include "ConfigLoader.h"
|
|
|
|
namespace
|
|
{
|
|
|
|
// Writes content to a file at path; creates parent directories if needed.
|
|
// Used to materialize malformed TOML for error-path coverage.
|
|
void writeFile(const std::filesystem::path& path, const std::string& content)
|
|
{
|
|
std::filesystem::create_directories(path.parent_path());
|
|
std::ofstream out(path);
|
|
out << content;
|
|
}
|
|
|
|
// RAII temp directory: allocated in ctor, recursively removed in dtor.
|
|
class TempConfigDir
|
|
{
|
|
public:
|
|
TempConfigDir()
|
|
{
|
|
const std::filesystem::path base = std::filesystem::temp_directory_path();
|
|
for (int i = 0; i < 10000; ++i)
|
|
{
|
|
const std::filesystem::path candidate =
|
|
base / ("dota_factory_test_" + std::to_string(i) + "_"
|
|
+ std::to_string(reinterpret_cast<std::uintptr_t>(this)));
|
|
if (!std::filesystem::exists(candidate))
|
|
{
|
|
std::filesystem::create_directories(candidate);
|
|
m_path = candidate;
|
|
return;
|
|
}
|
|
}
|
|
throw std::runtime_error("Could not allocate temp directory for test");
|
|
}
|
|
|
|
~TempConfigDir()
|
|
{
|
|
std::error_code ec;
|
|
std::filesystem::remove_all(m_path, ec);
|
|
}
|
|
|
|
TempConfigDir(const TempConfigDir&) = delete;
|
|
TempConfigDir& operator=(const TempConfigDir&) = delete;
|
|
|
|
const std::filesystem::path& path() const { return m_path; }
|
|
|
|
private:
|
|
std::filesystem::path m_path;
|
|
};
|
|
|
|
} // namespace
|
|
|
|
|
|
TEST_CASE("ConfigLoader loads the committed bin/config/ configs end-to-end", "[config]")
|
|
{
|
|
const std::string configDir = CONFIG_DIR;
|
|
const GameConfig cfg = ConfigLoader::loadFromDirectory(configDir);
|
|
|
|
// world.toml
|
|
REQUIRE(cfg.world.heightTiles == 60);
|
|
REQUIRE(cfg.world.refundPercentage == 75);
|
|
REQUIRE(cfg.world.beltSpeed_tps == Approx(2.0));
|
|
REQUIRE(cfg.world.regions.asteroidWidth_tiles == 40);
|
|
REQUIRE(cfg.world.regions.playerBufferWidth_tiles == 10);
|
|
REQUIRE(cfg.world.regions.enemyBufferWidth_tiles == 15);
|
|
REQUIRE(cfg.world.expansion.columnsPerExpansion_tiles == 10);
|
|
REQUIRE(cfg.world.expansion.costBuildingBlocksFormula.evaluate(0) == Approx(400.0));
|
|
REQUIRE(cfg.world.expansion.costBuildingBlocksFormula.evaluate(1) == Approx(800.0));
|
|
REQUIRE(cfg.world.push.bossAdvanceSeconds == Approx(60.0));
|
|
REQUIRE(cfg.world.orbitFactor == Approx(0.8));
|
|
REQUIRE(cfg.world.rallyOrbitRadius_tiles == Approx(5.0));
|
|
REQUIRE(cfg.world.scrapPerThreat == Approx(1.0));
|
|
|
|
// Optional header building blocks tooltip (REQ-UI-BLOCKS-TOOLTIP).
|
|
REQUIRE(cfg.world.buildingBlocksTooltip.has_value());
|
|
REQUIRE(*cfg.world.buildingBlocksTooltip ==
|
|
"Spend building blocks to build; deliver them to the HQ to gain more.");
|
|
|
|
// Optional header artifact tooltip (REQ-UI-ARTIFACTS-TOOLTIP).
|
|
REQUIRE(cfg.world.artifactTooltip.has_value());
|
|
REQUIRE(*cfg.world.artifactTooltip ==
|
|
"Choose the artifact reward when destroying enemy stations; collect enough to win.");
|
|
|
|
// Spot-check that a config-derived formula computes as expected.
|
|
// threat_rate_formula = "x": evaluates to the input value.
|
|
REQUIRE(cfg.world.waves.threatRateFormula.evaluate(1.0) == Approx(1.0));
|
|
REQUIRE(cfg.world.waves.threatRateFormula.evaluate(5.0) == Approx(5.0));
|
|
|
|
// targeting: distance score 1/(1+x) and overclaim penalty max(0.5, 1-0.1*x).
|
|
REQUIRE(cfg.world.targeting.hysteresis == Approx(0.10));
|
|
REQUIRE(cfg.world.targeting.targetScoreFormula.evaluate(0.0) == Approx(1.0));
|
|
REQUIRE(cfg.world.targeting.targetScoreFormula.evaluate(1.0) == Approx(0.5));
|
|
REQUIRE(cfg.world.targeting.overclaimPenaltyFormula.evaluate(0.0) == Approx(1.0));
|
|
REQUIRE(cfg.world.targeting.overclaimPenaltyFormula.evaluate(5.0) == Approx(0.5));
|
|
|
|
// buildings.toml
|
|
REQUIRE(cfg.buildings.buildings.size() >= 8);
|
|
const auto minerIt = std::find_if(
|
|
cfg.buildings.buildings.begin(), cfg.buildings.buildings.end(),
|
|
[](const BuildingDef& b) { return b.type == BuildingType::Miner; });
|
|
REQUIRE(minerIt != cfg.buildings.buildings.end());
|
|
REQUIRE(minerIt->cost == 15);
|
|
REQUIRE(minerIt->surfaceMask.size() == 2);
|
|
// Miner has no output-buffer-capacity override; the Salvage Bay does.
|
|
REQUIRE_FALSE(minerIt->outputBufferCapacity.has_value());
|
|
|
|
const auto salvageBayIt = std::find_if(
|
|
cfg.buildings.buildings.begin(), cfg.buildings.buildings.end(),
|
|
[](const BuildingDef& b) { return b.type == BuildingType::SalvageBay; });
|
|
REQUIRE(salvageBayIt != cfg.buildings.buildings.end());
|
|
REQUIRE(salvageBayIt->outputBufferCapacity.has_value());
|
|
REQUIRE(*salvageBayIt->outputBufferCapacity == 20);
|
|
|
|
// Optional per-building tooltip (REQ-UI-BUILD-TOOLTIP): the Salvage Bay
|
|
// defines one; the Miner leaves it unset.
|
|
REQUIRE(salvageBayIt->tooltip.has_value());
|
|
REQUIRE(*salvageBayIt->tooltip == "Drop-off point for salvage ships.");
|
|
REQUIRE_FALSE(minerIt->tooltip.has_value());
|
|
|
|
// recipes.toml — reprocessing cycle has three weighted outputs.
|
|
const auto reproIt = std::find_if(
|
|
cfg.recipes.recipes.begin(), cfg.recipes.recipes.end(),
|
|
[](const RecipeDef& r) { return r.id == "reprocessing_cycle"; });
|
|
REQUIRE(reproIt != cfg.recipes.recipes.end());
|
|
REQUIRE(reproIt->building == BuildingType::ReprocessingPlant);
|
|
REQUIRE(reproIt->outputs.size() == 3);
|
|
REQUIRE(reproIt->outputs[0].probability.has_value());
|
|
|
|
// Non-reprocessing recipes don't carry probability.
|
|
const auto ironIngotIt = std::find_if(
|
|
cfg.recipes.recipes.begin(), cfg.recipes.recipes.end(),
|
|
[](const RecipeDef& r) { return r.id == "iron_ingot"; });
|
|
REQUIRE(ironIngotIt != cfg.recipes.recipes.end());
|
|
REQUIRE(ironIngotIt->outputs.size() == 1);
|
|
REQUIRE_FALSE(ironIngotIt->outputs[0].probability.has_value());
|
|
|
|
// ships.toml — combat ships have default_modules with a weapon; salvage ships don't.
|
|
const auto interceptorIt = std::find_if(
|
|
cfg.ships.ships.begin(), cfg.ships.ships.end(),
|
|
[](const ShipDef& s) { return s.id == "interceptor"; });
|
|
REQUIRE(interceptorIt != cfg.ships.ships.end());
|
|
REQUIRE_FALSE(interceptorIt->defaultModules.empty());
|
|
REQUIRE(interceptorIt->defaultModules[0].moduleId == "laser_cannon");
|
|
|
|
const auto salvageShipIt = std::find_if(
|
|
cfg.ships.ships.begin(), cfg.ships.ships.end(),
|
|
[](const ShipDef& s) { return s.id == "salvage_ship"; });
|
|
REQUIRE(salvageShipIt != cfg.ships.ships.end());
|
|
REQUIRE(salvageShipIt->defaultModules.empty());
|
|
|
|
// modules.toml — optional per-module tooltip (REQ-MOD-UI-MODULE-TOOLTIP):
|
|
// armor_plate defines one; salvager leaves it unset.
|
|
const auto armorPlateIt = std::find_if(
|
|
cfg.modules.modules.begin(), cfg.modules.modules.end(),
|
|
[](const ModuleDef& m) { return m.id == "armor_plate"; });
|
|
REQUIRE(armorPlateIt != cfg.modules.modules.end());
|
|
REQUIRE(armorPlateIt->tooltip.has_value());
|
|
REQUIRE(*armorPlateIt->tooltip == "Adds a large flat bonus to hit points.");
|
|
|
|
const auto salvagerModuleIt = std::find_if(
|
|
cfg.modules.modules.begin(), cfg.modules.modules.end(),
|
|
[](const ModuleDef& m) { return m.id == "salvager"; });
|
|
REQUIRE(salvagerModuleIt != cfg.modules.modules.end());
|
|
REQUIRE_FALSE(salvagerModuleIt->tooltip.has_value());
|
|
|
|
// stations.toml
|
|
REQUIRE(cfg.stations.playerStation.level == 5);
|
|
REQUIRE(cfg.stations.playerStation.hpFormula.evaluate(5.0) == Approx(500.0)); // 300 + 40*5
|
|
REQUIRE(cfg.stations.enemyStation.hpFormula.evaluate(0.0) == Approx(300.0)); // 300 + 150*0
|
|
}
|
|
|
|
TEST_CASE("Loading a non-existent file throws", "[config]")
|
|
{
|
|
REQUIRE_THROWS(ConfigLoader::loadWorld("does/not/exist.toml"));
|
|
}
|
|
|
|
TEST_CASE("Malformed TOML is rejected with a file-identifying message", "[config]")
|
|
{
|
|
TempConfigDir dir;
|
|
writeFile(dir.path() / "world.toml", "this is = not [ valid toml\n");
|
|
|
|
try
|
|
{
|
|
ConfigLoader::loadWorld((dir.path() / "world.toml").string());
|
|
FAIL("Expected exception");
|
|
}
|
|
catch (const std::runtime_error& e)
|
|
{
|
|
const std::string msg = e.what();
|
|
REQUIRE(msg.find("world.toml") != std::string::npos);
|
|
}
|
|
}
|
|
|
|
TEST_CASE("Missing field in world.toml is rejected with the field path", "[config]")
|
|
{
|
|
TempConfigDir dir;
|
|
writeFile(dir.path() / "world.toml", R"(
|
|
[world]
|
|
height_tiles = 60
|
|
refund_percentage = 75
|
|
scrap_despawn_seconds = 30
|
|
scrap_per_threat = 0.01
|
|
tile_size_m = 10
|
|
belt_speed_mps = 20
|
|
starting_building_blocks = 100
|
|
tunnel_max_distance_tiles = 10
|
|
departure_interval_seconds = 20
|
|
orbit_factor = 0.8
|
|
rally_orbit_radius_tiles = 5.0
|
|
|
|
[regions]
|
|
asteroid_width_tiles = 40
|
|
player_buffer_width_tiles = 10
|
|
contest_zone_width_tiles = 30
|
|
# enemy_buffer_width_tiles intentionally missing
|
|
|
|
[expansion]
|
|
columns_per_expansion_tiles = 10
|
|
cost_building_blocks_formula = "400 * 2^x"
|
|
|
|
[push]
|
|
push_expand_columns_tiles = 20
|
|
scaling_factor = 1.2
|
|
|
|
[waves]
|
|
threat_rate_formula = "1*x - 30"
|
|
ship_level_formula = "1 + x / 120"
|
|
gap_min_seconds = 15
|
|
gap_max_seconds = 45
|
|
spawn_duration_seconds = 10
|
|
)");
|
|
|
|
try
|
|
{
|
|
ConfigLoader::loadWorld((dir.path() / "world.toml").string());
|
|
FAIL("Expected exception");
|
|
}
|
|
catch (const std::runtime_error& e)
|
|
{
|
|
const std::string msg = e.what();
|
|
REQUIRE(msg.find("enemy_buffer_width_tiles") != std::string::npos);
|
|
}
|
|
}
|
|
|
|
TEST_CASE("Malformed formula in world.toml is rejected with field identification", "[config]")
|
|
{
|
|
TempConfigDir dir;
|
|
writeFile(dir.path() / "world.toml", R"(
|
|
[world]
|
|
height_tiles = 60
|
|
refund_percentage = 75
|
|
scrap_despawn_seconds = 30
|
|
scrap_per_threat = 0.01
|
|
tile_size_m = 10
|
|
belt_speed_mps = 20
|
|
starting_building_blocks = 100
|
|
tunnel_max_distance_tiles = 10
|
|
departure_interval_seconds = 20
|
|
orbit_factor = 0.8
|
|
rally_orbit_radius_tiles = 5.0
|
|
|
|
[regions]
|
|
asteroid_width_tiles = 40
|
|
player_buffer_width_tiles = 10
|
|
contest_zone_width_tiles = 30
|
|
enemy_buffer_width_tiles = 15
|
|
|
|
[expansion]
|
|
columns_per_expansion_tiles = 10
|
|
cost_building_blocks_formula = "400 * 2^x"
|
|
|
|
[push]
|
|
push_expand_columns_tiles = 20
|
|
boss_advance_seconds = 60
|
|
|
|
[waves]
|
|
threat_rate_formula = "1 +"
|
|
ship_level_formula = "1 + x / 10"
|
|
gap_min_seconds = 15
|
|
gap_max_seconds = 45
|
|
spawn_duration_seconds = 10
|
|
)");
|
|
|
|
try
|
|
{
|
|
ConfigLoader::loadWorld((dir.path() / "world.toml").string());
|
|
FAIL("Expected exception");
|
|
}
|
|
catch (const std::runtime_error& e)
|
|
{
|
|
const std::string msg = e.what();
|
|
REQUIRE(msg.find("threat_rate_formula") != std::string::npos);
|
|
REQUIRE(msg.find("formula") != std::string::npos);
|
|
}
|
|
}
|
|
|
|
TEST_CASE("Inverted wave gap range is rejected", "[config]")
|
|
{
|
|
TempConfigDir dir;
|
|
writeFile(dir.path() / "world.toml", R"(
|
|
[world]
|
|
height_tiles = 60
|
|
refund_percentage = 75
|
|
scrap_despawn_seconds = 30
|
|
scrap_per_threat = 0.01
|
|
tile_size_m = 10
|
|
belt_speed_mps = 20
|
|
|
|
[regions]
|
|
asteroid_width_tiles = 40
|
|
player_buffer_width_tiles = 10
|
|
contest_zone_width_tiles = 30
|
|
enemy_buffer_width_tiles = 15
|
|
|
|
[expansion]
|
|
columns_per_expansion_tiles = 10
|
|
cost_building_blocks_formula = "400 * 2^x"
|
|
|
|
[push]
|
|
push_expand_columns_tiles = 20
|
|
scaling_factor = 1.2
|
|
|
|
[waves]
|
|
threat_rate_formula = "1*x - 30"
|
|
ship_level_formula = "1 + x / 120"
|
|
gap_min_seconds = 45
|
|
gap_max_seconds = 15
|
|
spawn_duration_seconds = 10
|
|
)");
|
|
|
|
REQUIRE_THROWS_AS(
|
|
ConfigLoader::loadWorld((dir.path() / "world.toml").string()),
|
|
std::runtime_error);
|
|
}
|
|
|
|
TEST_CASE("Unknown building id in buildings.toml is rejected with the id in the message", "[config]")
|
|
{
|
|
TempConfigDir dir;
|
|
writeFile(dir.path() / "buildings.toml", R"(
|
|
[[building]]
|
|
id = "fictional_machine"
|
|
cost = 10
|
|
player_placeable = true
|
|
construction_time_seconds = 5
|
|
surface_mask = ["AA"]
|
|
)");
|
|
|
|
try
|
|
{
|
|
ConfigLoader::loadBuildings((dir.path() / "buildings.toml").string());
|
|
FAIL("Expected exception");
|
|
}
|
|
catch (const std::runtime_error& e)
|
|
{
|
|
const std::string msg = e.what();
|
|
REQUIRE(msg.find("fictional_machine") != std::string::npos);
|
|
}
|
|
}
|
|
|
|
TEST_CASE("Recipe referencing an unknown building is rejected", "[config]")
|
|
{
|
|
TempConfigDir dir;
|
|
writeFile(dir.path() / "recipes.toml", R"(
|
|
[[recipe]]
|
|
id = "bogus"
|
|
building = "imaginary_factory"
|
|
inputs = []
|
|
outputs = [{item = "foo", amount = 1}]
|
|
duration_seconds = 1.0
|
|
)");
|
|
|
|
REQUIRE_THROWS_AS(
|
|
ConfigLoader::loadRecipes((dir.path() / "recipes.toml").string()),
|
|
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()));
|
|
}
|
|
|