#include "catch.hpp" #include #include #include #include #include #include #include #include "BuildingType.h" #include "ConfigLoader.h" #include "UnlocksConfig.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(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 deconstruction_time_seconds = 0.1 debris_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 deconstruction_time_seconds = 0.1 debris_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 deconstruction_time_seconds = 0.1 debris_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); } TEST_CASE("Optional recipe icon field parses; absent leaves it unset", "[config]") { TempConfigDir dir; writeFile(dir.path() / "recipes.toml", R"( [[recipe]] id = "with_icon" building = "assembler" inputs = [{item = "iron_ingot", amount = 1}] outputs = [{item = "steel_plate", amount = 1}] duration_seconds = 1.0 icon = "hardened_steel" [[recipe]] id = "without_icon" building = "assembler" inputs = [{item = "iron_ingot", amount = 1}] outputs = [{item = "copper_wire", amount = 1}] duration_seconds = 1.0 )"); const RecipesConfig cfg = ConfigLoader::loadRecipes((dir.path() / "recipes.toml").string()); const RecipeDef& withIcon = cfg.recipes.at(0); REQUIRE(withIcon.id == "with_icon"); REQUIRE(withIcon.icon.has_value()); REQUIRE(*withIcon.icon == "hardened_steel"); const RecipeDef& withoutIcon = cfg.recipes.at(1); REQUIRE(withoutIcon.id == "without_icon"); REQUIRE_FALSE(withoutIcon.icon.has_value()); } // --- 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("loadUnlocks parses id, station_level, requires, and grants", "[config]") { TempConfigDir dir; writeFile(dir.path() / "unlocks.toml", R"( [[unlock]] id = "base" station_level = 0 buildings = ["salvage_bay"] [[unlock]] id = "gated" station_level = 3 requires = ["base"] ships = ["cruiser"] modules = ["railgun_m"] recipes = ["shortcut_steel_plate"] )"); const UnlocksConfig cfg = ConfigLoader::loadUnlocks((dir.path() / "unlocks.toml").string()); REQUIRE(cfg.groups.size() == 2); CHECK(cfg.groups[0].id == "base"); CHECK(cfg.groups[0].stationLevel == 0); CHECK(cfg.groups[0].buildings == std::vector{"salvage_bay"}); CHECK(cfg.groups[1].requiredGroupIds == std::vector{"base"}); CHECK(cfg.groups[1].ships == std::vector{"cruiser"}); CHECK(cfg.groups[1].modules == std::vector{"railgun_m"}); CHECK(cfg.groups[1].recipes == std::vector{"shortcut_steel_plate"}); } TEST_CASE("validateUnlocks rejects a grant that names no definition", "[config]") { TempConfigDir dir; copyConfigInto(dir.path()); std::ofstream out((dir.path() / "unlocks.toml").string(), std::ios::app); out << "\n[[unlock]]\nid = \"bogus\"\nstation_level = 0\nships = [\"___nope___\"]\n"; out.close(); try { ConfigLoader::loadFromDirectory(dir.path().string()); FAIL("Expected exception"); } catch (const std::runtime_error& e) { REQUIRE(std::string(e.what()).find("___nope___") != std::string::npos); } } TEST_CASE("validateUnlocks rejects an id granted by two groups", "[config]") { TempConfigDir dir; copyConfigInto(dir.path()); // repair_ship is already granted by the test config's unlocks.toml. std::ofstream out((dir.path() / "unlocks.toml").string(), std::ios::app); out << "\n[[unlock]]\nid = \"dup\"\nstation_level = 0\nships = [\"repair_ship\"]\n"; out.close(); try { ConfigLoader::loadFromDirectory(dir.path().string()); FAIL("Expected exception"); } catch (const std::runtime_error& e) { REQUIRE(std::string(e.what()).find("already granted") != std::string::npos); } } TEST_CASE("validateUnlocks rejects a non-assembler recipe grant", "[config]") { TempConfigDir dir; copyConfigInto(dir.path()); // mine_iron_ore is a miner recipe, not an assembler recipe. std::ofstream out((dir.path() / "unlocks.toml").string(), std::ios::app); out << "\n[[unlock]]\nid = \"badrecipe\"\nstation_level = 0\nrecipes = [\"mine_iron_ore\"]\n"; out.close(); try { ConfigLoader::loadFromDirectory(dir.path().string()); FAIL("Expected exception"); } catch (const std::runtime_error& e) { REQUIRE(std::string(e.what()).find("mine_iron_ore") != std::string::npos); } } TEST_CASE("validateUnlocks rejects requires naming an unknown group", "[config]") { TempConfigDir dir; copyConfigInto(dir.path()); std::ofstream out((dir.path() / "unlocks.toml").string(), std::ios::app); out << "\n[[unlock]]\nid = \"needsmissing\"\nstation_level = 0\n" "requires = [\"___missing___\"]\nships = [\"interceptor\"]\n"; out.close(); try { ConfigLoader::loadFromDirectory(dir.path().string()); FAIL("Expected exception"); } catch (const std::runtime_error& e) { REQUIRE(std::string(e.what()).find("___missing___") != std::string::npos); } } TEST_CASE("validateUnlocks rejects a group that grants nothing", "[config]") { TempConfigDir dir; copyConfigInto(dir.path()); std::ofstream out((dir.path() / "unlocks.toml").string(), std::ios::app); out << "\n[[unlock]]\nid = \"empty\"\nstation_level = 0\n"; out.close(); try { ConfigLoader::loadFromDirectory(dir.path().string()); FAIL("Expected exception"); } catch (const std::runtime_error& e) { REQUIRE(std::string(e.what()).find("grants no items") != std::string::npos); } } TEST_CASE("validateUnlocks accepts a well-formed unlock group", "[config]") { TempConfigDir dir; copyConfigInto(dir.path()); // salvage_ship starts unlocked (not granted elsewhere); repair_ship is an // existing group id, so this new group is valid. std::ofstream out((dir.path() / "unlocks.toml").string(), std::ios::app); out << "\n[[unlock]]\nid = \"extra\"\nstation_level = 2\n" "requires = [\"repair_ship\"]\nships = [\"salvage_ship\"]\n"; out.close(); REQUIRE_NOTHROW(ConfigLoader::loadFromDirectory(dir.path().string())); }