Files
dota_factory/src/test/ConfigLoaderTest.cpp
Malte Langkabel 9c275e283c give every recipe one shape: a list of output groups
Implements REQ-MAT-OUTPUT-GROUP. A recipe had two shapes -- outputs produced
together, or outputs of which exactly one happened -- and every rule over them
was written twice, selected by `building == ReprocessingPlant`: sizing a
buffer, deciding whether a cycle fits, resolving what a cycle makes, costing an
item. RecipeDef now holds output groups, each a weight and a list of items, and
a cycle yields exactly one group. One group is the ordinary recipe, so the old
two cases are the same shape with one and with several, and all four rules
collapse to one expression apiece with no building-type test left.

rollReprocessingOutput becomes rollOutputGroup, where a single group returns
without drawing or testing eligibility. That early-out is load-bearing twice
over. Drawing there would consume entropy for every ordinary recipe and shift
every later random outcome; and eligibility must not apply either, since
implicit unlocking is demand-derived, so an ordinary recipe's output can be
producible while nothing yet calls for it -- testing it would stop the building
producing rather than gate a drop. Past the early-out a group is eligible only
when all of its items are unlocked, being produced whole.

Threat follows the recipe's shape rather than the building, and the per-unit
value now divides by the group's amount as well as its odds. That moves no
number today: every item resolved through this path has amount 1, which is why
the threat expectations are untouched.

Config keeps `outputs = [...]` as the single-group form, so only the two
reprocessing recipes change shape. The recipe summary gains "/" between groups
and keeps "+" within one, which also fixes the plant reading as though a cycle
produced all of its items at once.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ne3mejABZoLWKLh8fgpM3x
2026-08-17 12:47:09 +02:00

565 lines
18 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"
#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<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 -- the reprocessing cycle is written as three weighted output groups,
// each yielding one item (REQ-MAT-OUTPUT-GROUP).
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->outputGroups.size() == 3);
REQUIRE(reproIt->outputGroups[0].probability.has_value());
REQUIRE(reproIt->outputGroups[0].items.size() == 1);
// The `outputs = [...]` shorthand loads as one group carrying no weight: with a single
// group nothing is picked, so there is nothing to weigh.
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->outputGroups.size() == 1);
REQUIRE(ironIngotIt->outputGroups[0].items.size() == 1);
REQUIRE_FALSE(ironIngotIt->outputGroups[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);
}
// --- 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<std::string>{"salvage_bay"});
CHECK(cfg.groups[1].requiredGroupIds == std::vector<std::string>{"base"});
CHECK(cfg.groups[1].ships == std::vector<std::string>{"cruiser"});
CHECK(cfg.groups[1].modules == std::vector<std::string>{"railgun_m"});
CHECK(cfg.groups[1].recipes == std::vector<std::string>{"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()));
}