diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 44efa06..4cab909 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -56,6 +56,7 @@ set_property(TARGET ${TARGET_LIB_NAME} PROPERTY INCLUDE_DIRECTORIES "${LIB_INCLUDE_PATH}" ) target_link_libraries(${TARGET_LIB_NAME} Qt5::Core) +target_compile_definitions(${TARGET_LIB_NAME} PRIVATE TOML_FLOAT_CHARCONV=0) set(CMAKE_AUTOMOC OFF) @@ -64,7 +65,8 @@ unset(RELATIVE_HDRS) unset(RELATIVE_SRCS) unset(HDRS) unset(SRCS) -unset(LIB_INCLUDE_PATH) +# LIB_INCLUDE_PATH (populated by lib/ and external/ subdirectories) is kept +# in scope so the test target below can also reach those headers. # ============================================================ @@ -90,6 +92,7 @@ target_link_libraries(${TARGET_UI_NAME} INTERFACE target_include_directories(${TARGET_UI_NAME} INTERFACE "${TARGET_UI_INCLUDE_DIRS}" "${TARGET_LIB_INCLUDE_DIRS}" + "${LIB_INCLUDE_PATH}" ) @@ -166,6 +169,10 @@ set_property(TARGET ${TARGET_TEST_NAME} PROPERTY CXX_STANDARD 17) set_property(TARGET ${TARGET_TEST_NAME} PROPERTY INCLUDE_DIRECTORIES "${TARGET_TEST_INCLUDE_DIRS}" "${TARGET_LIB_INCLUDE_DIRS}" + "${LIB_INCLUDE_PATH}" +) +target_compile_definitions(${TARGET_TEST_NAME} PRIVATE + DOTA_FACTORY_CONFIG_DIR="${CMAKE_SOURCE_DIR}/bin/config" ) target_link_libraries(${TARGET_TEST_NAME} ${TARGET_LIB_NAME}) diff --git a/src/external/CMakeLists.txt b/src/external/CMakeLists.txt index 32bd8f9..161b3bf 100644 --- a/src/external/CMakeLists.txt +++ b/src/external/CMakeLists.txt @@ -7,3 +7,16 @@ add_files( toml++/toml.hpp toml++/toml.h ) + +# Expose each external library's own directory on the include path so that +# source files can reference its headers without a subdirectory prefix: +# #include "catch.hpp" // instead of "catch/catch.hpp" +# #include "tinyexpr.h" // instead of "tinyexpr/tinyexpr.h" +# #include "toml.hpp" // instead of "toml++/toml.hpp" +set(LIB_INCLUDE_PATH + ${LIB_INCLUDE_PATH} + ${CMAKE_CURRENT_SOURCE_DIR}/catch + ${CMAKE_CURRENT_SOURCE_DIR}/tinyexpr + ${CMAKE_CURRENT_SOURCE_DIR}/toml++ + PARENT_SCOPE +) diff --git a/src/lib/CMakeLists.txt b/src/lib/CMakeLists.txt index e79b2c7..6244394 100644 --- a/src/lib/CMakeLists.txt +++ b/src/lib/CMakeLists.txt @@ -1,7 +1,8 @@ SET(HDRS) SET(SRCS) -SET(LIB_INCLUDE_PATH) +add_subdirectory(core) +add_subdirectory(config) add_subdirectory(utility) SET(HDRS diff --git a/src/lib/config/BuildingsConfig.h b/src/lib/config/BuildingsConfig.h new file mode 100644 index 0000000..c6fac17 --- /dev/null +++ b/src/lib/config/BuildingsConfig.h @@ -0,0 +1,26 @@ +#pragma once + +#include +#include + +#include "BuildingType.h" + +// A single entry from buildings.toml [[building]]. +struct BuildingDef +{ + std::string id; // Raw id string from TOML, e.g. "miner". + BuildingType type; // Parsed from id at load time. + int cost; // REQ-BLD-COST + bool playerPlaceable; // Shown in the build menu if true. + double constructionTimeSeconds; // REQ-BLD-QUEUE + + // Rows of the surface_mask (REQ-BLD requirements, "Surface Mask Format"). + // Stored as raw strings here; parsing into per-cell tiles + output ports + // happens when buildings are placed, not at load time. + std::vector surfaceMask; +}; + +struct BuildingsConfig +{ + std::vector buildings; +}; diff --git a/src/lib/config/CMakeLists.txt b/src/lib/config/CMakeLists.txt new file mode 100644 index 0000000..3fc76d2 --- /dev/null +++ b/src/lib/config/CMakeLists.txt @@ -0,0 +1,25 @@ +SET(HDRS + ${HDRS} + ${CMAKE_CURRENT_SOURCE_DIR}/Formula.h + ${CMAKE_CURRENT_SOURCE_DIR}/WorldConfig.h + ${CMAKE_CURRENT_SOURCE_DIR}/BuildingsConfig.h + ${CMAKE_CURRENT_SOURCE_DIR}/RecipesConfig.h + ${CMAKE_CURRENT_SOURCE_DIR}/ShipsConfig.h + ${CMAKE_CURRENT_SOURCE_DIR}/StationsConfig.h + ${CMAKE_CURRENT_SOURCE_DIR}/GameConfig.h + ${CMAKE_CURRENT_SOURCE_DIR}/ConfigLoader.h + PARENT_SCOPE +) + +SET(SRCS + ${SRCS} + ${CMAKE_CURRENT_SOURCE_DIR}/Formula.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/ConfigLoader.cpp + PARENT_SCOPE +) + +set(LIB_INCLUDE_PATH + ${LIB_INCLUDE_PATH} + ${CMAKE_CURRENT_SOURCE_DIR} + PARENT_SCOPE +) diff --git a/src/lib/config/ConfigLoader.cpp b/src/lib/config/ConfigLoader.cpp new file mode 100644 index 0000000..e942160 --- /dev/null +++ b/src/lib/config/ConfigLoader.cpp @@ -0,0 +1,496 @@ +#include "ConfigLoader.h" + +#include +#include +#include +#include +#include +#include + +#include "toml.hpp" + +namespace +{ + +// --- Error helpers -------------------------------------------------------- + +std::runtime_error makeError(const std::string& file, + const std::string& path, + const std::string& why) +{ + return std::runtime_error("Config: " + file + ": '" + path + "' " + why); +} + +// --- Typed accessors (throw on missing or wrong type) --------------------- + +int64_t requireInt(const toml::node_view& node, + const std::string& file, + const std::string& path) +{ + const std::optional value = node.value(); + if (!value) + { + throw makeError(file, path, "missing or not an integer"); + } + return *value; +} + +double requireDouble(const toml::node_view& node, + const std::string& file, + const std::string& path) +{ + if (const std::optional v = node.value()) + { + return *v; + } + if (const std::optional v = node.value()) + { + return static_cast(*v); + } + throw makeError(file, path, "missing or not a number"); +} + +std::string requireString(const toml::node_view& node, + const std::string& file, + const std::string& path) +{ + const std::optional value = node.value(); + if (!value) + { + throw makeError(file, path, "missing or not a string"); + } + return *value; +} + +bool requireBool(const toml::node_view& node, + const std::string& file, + const std::string& path) +{ + const std::optional value = node.value(); + if (!value) + { + throw makeError(file, path, "missing or not a boolean"); + } + return *value; +} + +const toml::array& requireArray(const toml::node_view& node, + const std::string& file, + const std::string& path) +{ + const toml::array* arr = node.as_array(); + if (arr == nullptr) + { + throw makeError(file, path, "missing or not an array"); + } + return *arr; +} + +const toml::table& requireTable(const toml::node_view& node, + const std::string& file, + const std::string& path) +{ + const toml::table* tbl = node.as_table(); + if (tbl == nullptr) + { + throw makeError(file, path, "missing or not a table"); + } + return *tbl; +} + +Formula requireFormula(const toml::node_view& node, + const std::string& file, + const std::string& path) +{ + const std::string source = requireString(node, file, path); + try + { + return Formula::compile(source); + } + catch (const std::exception& e) + { + throw makeError(file, path, std::string("formula error: ") + e.what()); + } +} + +std::vector requireStringArray(const toml::node_view& node, + const std::string& file, + const std::string& path) +{ + const toml::array& arr = requireArray(node, file, path); + std::vector result; + result.reserve(arr.size()); + for (std::size_t i = 0; i < arr.size(); ++i) + { + const std::string elemPath = path + "[" + std::to_string(i) + "]"; + const std::optional s = arr[i].value(); + if (!s) + { + throw makeError(file, elemPath, "not a string"); + } + result.push_back(*s); + } + return result; +} + +std::vector parseIngredients(const toml::array& arr, + const std::string& file, + const std::string& path) +{ + std::vector result; + result.reserve(arr.size()); + for (std::size_t i = 0; i < arr.size(); ++i) + { + const std::string elemPath = path + "[" + std::to_string(i) + "]"; + const toml::table* t = arr[i].as_table(); + if (t == nullptr) + { + throw makeError(file, elemPath, "not a table"); + } + + // We need a mutable node_view to reuse our helpers, which is fine + // because the helpers never mutate. + toml::table& mt = const_cast(*t); + + RecipeIngredient ing; + ing.item = requireString(mt["item"], file, elemPath + ".item"); + ing.amount = static_cast(requireInt(mt["amount"], file, elemPath + ".amount")); + result.push_back(std::move(ing)); + } + return result; +} + +std::vector parseRecipeOutputs(const toml::array& arr, + const std::string& file, + const std::string& path) +{ + std::vector result; + result.reserve(arr.size()); + for (std::size_t i = 0; i < arr.size(); ++i) + { + const std::string elemPath = path + "[" + std::to_string(i) + "]"; + const toml::table* t = arr[i].as_table(); + if (t == nullptr) + { + throw makeError(file, elemPath, "not a table"); + } + toml::table& mt = const_cast(*t); + + RecipeOutput out; + out.item = requireString(mt["item"], file, elemPath + ".item"); + out.amount = static_cast(requireInt(mt["amount"], file, elemPath + ".amount")); + if (const std::optional p = mt["probability"].value()) + { + out.probability = *p; + } + else if (const std::optional p = mt["probability"].value()) + { + out.probability = static_cast(*p); + } + result.push_back(std::move(out)); + } + return result; +} + +toml::table parseFile(const std::string& path, const std::string& file) +{ + try + { + return toml::parse_file(path); + } + catch (const toml::parse_error& e) + { + std::ostringstream oss; + oss << "Config: " << file << ": TOML parse error: " << e.description() + << " at " << e.source().begin; + throw std::runtime_error(oss.str()); + } +} + +} // namespace + + +// --- Per-file loaders ----------------------------------------------------- + +WorldConfig ConfigLoader::loadWorld(const std::string& path) +{ + const std::string file = "world.toml"; + toml::table tbl = parseFile(path, file); + + WorldConfig cfg; + + cfg.heightTiles = static_cast(requireInt(tbl["world"]["height_tiles"], file, "world.height_tiles")); + cfg.refundPercentage = static_cast(requireInt(tbl["world"]["refund_percentage"], file, "world.refund_percentage")); + cfg.scrapDespawnSeconds = requireDouble(tbl["world"]["scrap_despawn_seconds"], file, "world.scrap_despawn_seconds"); + + cfg.regions.asteroidWidth = static_cast(requireInt(tbl["regions"]["asteroid_width"], file, "regions.asteroid_width")); + cfg.regions.playerBufferWidth = static_cast(requireInt(tbl["regions"]["player_buffer_width"], file, "regions.player_buffer_width")); + cfg.regions.contestZoneWidth = static_cast(requireInt(tbl["regions"]["contest_zone_width"], file, "regions.contest_zone_width")); + cfg.regions.enemyBufferWidth = static_cast(requireInt(tbl["regions"]["enemy_buffer_width"], file, "regions.enemy_buffer_width")); + + cfg.expansion.columnsPerExpansion = static_cast(requireInt(tbl["expansion"]["columns_per_expansion"], file, "expansion.columns_per_expansion")); + cfg.expansion.costBuildingBlocks = static_cast(requireInt(tbl["expansion"]["cost_building_blocks"], file, "expansion.cost_building_blocks")); + + cfg.push.pushExpandColumns = static_cast(requireInt(tbl["push"]["push_expand_columns"], file, "push.push_expand_columns")); + cfg.push.scalingFactor = requireDouble(tbl["push"]["scaling_factor"], file, "push.scaling_factor"); + + cfg.waves.threatRateFormula = requireFormula(tbl["waves"]["threat_rate_formula"], file, "waves.threat_rate_formula"); + cfg.waves.shipLevelFormula = requireFormula(tbl["waves"]["ship_level_formula"], file, "waves.ship_level_formula"); + cfg.waves.gapMinSeconds = requireDouble(tbl["waves"]["gap_min_seconds"], file, "waves.gap_min_seconds"); + cfg.waves.gapMaxSeconds = requireDouble(tbl["waves"]["gap_max_seconds"], file, "waves.gap_max_seconds"); + cfg.waves.spawnDurationSeconds = requireDouble(tbl["waves"]["spawn_duration_seconds"], file, "waves.spawn_duration_seconds"); + + if (cfg.waves.gapMinSeconds > cfg.waves.gapMaxSeconds) + { + throw makeError(file, "waves", "gap_min_seconds > gap_max_seconds"); + } + + return cfg; +} + +BuildingsConfig ConfigLoader::loadBuildings(const std::string& path) +{ + const std::string file = "buildings.toml"; + toml::table tbl = parseFile(path, file); + + BuildingsConfig cfg; + const toml::array& arr = requireArray(tbl["building"], file, "building"); + + for (std::size_t i = 0; i < arr.size(); ++i) + { + const std::string elemPath = "building[" + std::to_string(i) + "]"; + const toml::table* bt = arr[i].as_table(); + if (bt == nullptr) + { + throw makeError(file, elemPath, "not a table"); + } + toml::table& mt = const_cast(*bt); + + BuildingDef def; + def.id = requireString(mt["id"], file, elemPath + ".id"); + def.cost = static_cast(requireInt(mt["cost"], file, elemPath + ".cost")); + def.playerPlaceable = requireBool(mt["player_placeable"], file, elemPath + ".player_placeable"); + def.constructionTimeSeconds = requireDouble(mt["construction_time_seconds"], file, elemPath + ".construction_time_seconds"); + def.surfaceMask = requireStringArray(mt["surface_mask"], file, elemPath + ".surface_mask"); + + const std::optional parsedType = parseBuildingType(def.id); + if (!parsedType) + { + throw makeError(file, elemPath + ".id", "unknown building id '" + def.id + "'"); + } + def.type = *parsedType; + + cfg.buildings.push_back(std::move(def)); + } + + return cfg; +} + +RecipesConfig ConfigLoader::loadRecipes(const std::string& path) +{ + const std::string file = "recipes.toml"; + toml::table tbl = parseFile(path, file); + + RecipesConfig cfg; + const toml::array& arr = requireArray(tbl["recipe"], file, "recipe"); + + for (std::size_t i = 0; i < arr.size(); ++i) + { + const std::string elemPath = "recipe[" + std::to_string(i) + "]"; + const toml::table* rt = arr[i].as_table(); + if (rt == nullptr) + { + throw makeError(file, elemPath, "not a table"); + } + toml::table& mt = const_cast(*rt); + + RecipeDef def; + def.id = requireString(mt["id"], file, elemPath + ".id"); + def.durationSeconds = requireDouble(mt["duration_seconds"], file, elemPath + ".duration_seconds"); + + const std::string buildingId = requireString(mt["building"], file, elemPath + ".building"); + const std::optional parsedType = parseBuildingType(buildingId); + if (!parsedType) + { + throw makeError(file, elemPath + ".building", + "unknown building id '" + buildingId + "'"); + } + def.building = *parsedType; + + // inputs may be omitted (e.g. miner recipes). An empty array is fine. + if (mt.contains("inputs")) + { + const toml::array& inputs = requireArray(mt["inputs"], file, elemPath + ".inputs"); + def.inputs = parseIngredients(inputs, file, elemPath + ".inputs"); + } + + const toml::array& outputs = requireArray(mt["outputs"], file, elemPath + ".outputs"); + def.outputs = parseRecipeOutputs(outputs, file, elemPath + ".outputs"); + + cfg.recipes.push_back(std::move(def)); + } + + return cfg; +} + +ShipsConfig ConfigLoader::loadShips(const std::string& path) +{ + const std::string file = "ships.toml"; + toml::table tbl = parseFile(path, file); + + ShipsConfig cfg; + const toml::array& arr = requireArray(tbl["ship"], file, "ship"); + + for (std::size_t i = 0; i < arr.size(); ++i) + { + const std::string elemPath = "ship[" + std::to_string(i) + "]"; + const toml::table* st = arr[i].as_table(); + if (st == nullptr) + { + throw makeError(file, elemPath, "not a table"); + } + toml::table& mt = const_cast(*st); + + ShipDef def; + def.id = requireString(mt["id"], file, elemPath + ".id"); + def.availableFromStart = requireBool(mt["available_from_start"], file, elemPath + ".available_from_start"); + + // Blueprint + { + const std::string bpPath = elemPath + ".blueprint"; + const toml::table& bpTable = requireTable(mt["blueprint"], file, bpPath); + toml::table& bpMt = const_cast(bpTable); + + const toml::array& materials = requireArray(bpMt["materials"], file, bpPath + ".materials"); + def.blueprint.materials = parseIngredients(materials, file, bpPath + ".materials"); + def.blueprint.playerProductionLevel = static_cast(requireInt( + bpMt["player_production_level"], file, bpPath + ".player_production_level")); + } + + // Threat + { + const std::string tPath = elemPath + ".threat"; + const toml::table& tTable = requireTable(mt["threat"], file, tPath); + toml::table& tMt = const_cast(tTable); + def.threat.costFormula = requireFormula(tMt["cost_formula"], file, tPath + ".cost_formula"); + } + + // Health + { + const std::string hPath = elemPath + ".health"; + const toml::table& hTable = requireTable(mt["health"], file, hPath); + toml::table& hMt = const_cast(hTable); + def.health.hpFormula = requireFormula(hMt["hp_formula"], file, hPath + ".hp_formula"); + } + + // Movement + { + const std::string mPath = elemPath + ".movement"; + const toml::table& mTable = requireTable(mt["movement"], file, mPath); + toml::table& mMt = const_cast(mTable); + def.movement.speedFormula = requireFormula(mMt["speed_formula"], file, mPath + ".speed_formula"); + } + + // Loot + { + const std::string lPath = elemPath + ".loot"; + const toml::table& lTable = requireTable(mt["loot"], file, lPath); + toml::table& lMt = const_cast(lTable); + def.loot.scrapDrop = static_cast(requireInt(lMt["scrap_drop"], file, lPath + ".scrap_drop")); + } + + // Optional: combat + if (mt.contains("combat")) + { + const std::string cPath = elemPath + ".combat"; + const toml::table& cTable = requireTable(mt["combat"], file, cPath); + toml::table& cMt = const_cast(cTable); + ShipCombat combat { + requireFormula(cMt["damage_formula"], file, cPath + ".damage_formula"), + requireFormula(cMt["attack_range_formula"], file, cPath + ".attack_range_formula"), + requireFormula(cMt["attack_rate_formula"], file, cPath + ".attack_rate_formula"), + }; + def.combat = std::move(combat); + } + + // Optional: salvage + if (mt.contains("salvage")) + { + const std::string sPath = elemPath + ".salvage"; + const toml::table& sTable = requireTable(mt["salvage"], file, sPath); + toml::table& sMt = const_cast(sTable); + ShipSalvage salvage; + salvage.collectionRange = requireDouble(sMt["collection_range"], file, sPath + ".collection_range"); + salvage.cargoCapacity = static_cast(requireInt(sMt["cargo_capacity"], file, sPath + ".cargo_capacity")); + def.salvage = salvage; + } + + // Optional: repair + if (mt.contains("repair")) + { + const std::string rPath = elemPath + ".repair"; + const toml::table& rTable = requireTable(mt["repair"], file, rPath); + toml::table& rMt = const_cast(rTable); + ShipRepair repair { + requireFormula(rMt["repair_rate_formula"], file, rPath + ".repair_rate_formula"), + requireFormula(rMt["repair_range_formula"], file, rPath + ".repair_range_formula"), + }; + def.repair = std::move(repair); + } + + cfg.ships.push_back(std::move(def)); + } + + return cfg; +} + +StationsConfig ConfigLoader::loadStations(const std::string& path) +{ + const std::string file = "stations.toml"; + toml::table tbl = parseFile(path, file); + + StationsConfig cfg; + + // HQ + { + const std::string p = "hq"; + cfg.hq.surfaceMask = requireStringArray(tbl[p]["surface_mask"], file, p + ".surface_mask"); + cfg.hq.hpFormula = requireFormula(tbl[p]["hp_formula"], file, p + ".hp_formula"); + } + + // Player station + { + const std::string p = "player_station"; + cfg.playerStation.surfaceMask = requireStringArray(tbl[p]["surface_mask"], file, p + ".surface_mask"); + cfg.playerStation.level = static_cast(requireInt(tbl[p]["level"], file, p + ".level")); + cfg.playerStation.hpFormula = requireFormula(tbl[p]["hp_formula"], file, p + ".hp_formula"); + cfg.playerStation.damageFormula = requireFormula(tbl[p]["damage_formula"], file, p + ".damage_formula"); + cfg.playerStation.rangeFormula = requireFormula(tbl[p]["range_formula"], file, p + ".range_formula"); + cfg.playerStation.fireRateFormula = requireFormula(tbl[p]["fire_rate_formula"], file, p + ".fire_rate_formula"); + cfg.playerStation.scrapDropFormula = requireFormula(tbl[p]["scrap_drop_formula"], file, p + ".scrap_drop_formula"); + } + + // Enemy station + { + const std::string p = "enemy_station"; + cfg.enemyStation.surfaceMask = requireStringArray(tbl[p]["surface_mask"], file, p + ".surface_mask"); + cfg.enemyStation.hpFormula = requireFormula(tbl[p]["hp_formula"], file, p + ".hp_formula"); + cfg.enemyStation.damageFormula = requireFormula(tbl[p]["damage_formula"], file, p + ".damage_formula"); + cfg.enemyStation.rangeFormula = requireFormula(tbl[p]["range_formula"], file, p + ".range_formula"); + cfg.enemyStation.fireRateFormula = requireFormula(tbl[p]["fire_rate_formula"], file, p + ".fire_rate_formula"); + cfg.enemyStation.scrapDropFormula = requireFormula(tbl[p]["scrap_drop_formula"], file, p + ".scrap_drop_formula"); + } + + return cfg; +} + +GameConfig ConfigLoader::loadFromDirectory(const std::string& configDir) +{ + GameConfig cfg; + cfg.world = loadWorld(configDir + "/world.toml"); + cfg.buildings = loadBuildings(configDir + "/buildings.toml"); + cfg.recipes = loadRecipes(configDir + "/recipes.toml"); + cfg.ships = loadShips(configDir + "/ships.toml"); + cfg.stations = loadStations(configDir + "/stations.toml"); + return cfg; +} diff --git a/src/lib/config/ConfigLoader.h b/src/lib/config/ConfigLoader.h new file mode 100644 index 0000000..7983f44 --- /dev/null +++ b/src/lib/config/ConfigLoader.h @@ -0,0 +1,24 @@ +#pragma once + +#include + +#include "GameConfig.h" + +// Parses the five simulation TOML files from a directory and returns a fully +// populated, immutable GameConfig. Throws std::runtime_error on any parse or +// validation failure; the exception message identifies the offending file, +// field, or formula (see architecture.md "Config Loading"). +// +// Per-file helpers are exposed so tests can exercise individual loaders in +// isolation. +class ConfigLoader +{ +public: + static GameConfig loadFromDirectory(const std::string& configDir); + + static WorldConfig loadWorld(const std::string& path); + static BuildingsConfig loadBuildings(const std::string& path); + static RecipesConfig loadRecipes(const std::string& path); + static ShipsConfig loadShips(const std::string& path); + static StationsConfig loadStations(const std::string& path); +}; diff --git a/src/lib/config/Formula.cpp b/src/lib/config/Formula.cpp new file mode 100644 index 0000000..6dfbdaa --- /dev/null +++ b/src/lib/config/Formula.cpp @@ -0,0 +1,68 @@ +#include "Formula.h" + +#include + +#include "tinyexpr.h" + +Formula::Formula(Formula&& other) noexcept + : m_source(std::move(other.m_source)) + , m_x(std::move(other.m_x)) + , m_expr(other.m_expr) +{ + other.m_expr = nullptr; +} + +Formula& Formula::operator=(Formula&& other) noexcept +{ + if (this != &other) + { + te_free(m_expr); + m_source = std::move(other.m_source); + m_x = std::move(other.m_x); + m_expr = other.m_expr; + other.m_expr = nullptr; + } + return *this; +} + +Formula::~Formula() +{ + te_free(m_expr); +} + +Formula Formula::compile(const std::string& source) +{ + Formula result; + result.m_source = source; + result.m_x = std::make_unique(0.0); + + const te_variable variables[] = { + { "x", result.m_x.get(), 0, nullptr }, + }; + + int errorPos = 0; + result.m_expr = te_compile(result.m_source.c_str(), variables, 1, &errorPos); + + if (result.m_expr == nullptr) + { + throw std::runtime_error( + "Formula parse error at position " + std::to_string(errorPos) + + " in \"" + source + "\""); + } + + return result; +} + +double Formula::evaluate(double x) const +{ + if (m_expr == nullptr) + { + throw std::runtime_error("Formula::evaluate called on uninitialized formula"); + } + + // The variable at *m_x is what the compiled tree dereferences during + // te_eval. Mutating it here does not affect logical const-ness of the + // formula — the formula itself is unchanged. + *m_x = x; + return te_eval(m_expr); +} diff --git a/src/lib/config/Formula.h b/src/lib/config/Formula.h new file mode 100644 index 0000000..e9b5b33 --- /dev/null +++ b/src/lib/config/Formula.h @@ -0,0 +1,40 @@ +#pragma once + +#include +#include + +// Forward declaration so tinyexpr.h stays out of this header. +struct te_expr; + +// Compiled single-variable expression. The bound variable is named x. +// Compile once at config load; evaluate many times at simulation time. +// tinyexpr bakes the address of the bound variable into the compiled tree, so +// we keep x on the heap to preserve that pointer across moves. +class Formula +{ +public: + Formula() = default; + + Formula(const Formula&) = delete; + Formula& operator=(const Formula&) = delete; + + Formula(Formula&& other) noexcept; + Formula& operator=(Formula&& other) noexcept; + + ~Formula(); + + // Parses source and returns a ready-to-evaluate Formula. + // Throws std::runtime_error with the source and error position on failure. + static Formula compile(const std::string& source); + + // Evaluates the expression at the given x. Requires a compiled formula. + double evaluate(double x) const; + + const std::string& source() const { return m_source; } + bool isValid() const { return m_expr != nullptr; } + +private: + std::string m_source; + std::unique_ptr m_x; + te_expr* m_expr = nullptr; +}; diff --git a/src/lib/config/GameConfig.h b/src/lib/config/GameConfig.h new file mode 100644 index 0000000..5a22c30 --- /dev/null +++ b/src/lib/config/GameConfig.h @@ -0,0 +1,18 @@ +#pragma once + +#include "WorldConfig.h" +#include "BuildingsConfig.h" +#include "RecipesConfig.h" +#include "ShipsConfig.h" +#include "StationsConfig.h" + +// Aggregate of all five simulation config files, loaded once at startup and +// immutable for the rest of the game. See architecture.md "Config Loading". +struct GameConfig +{ + WorldConfig world; + BuildingsConfig buildings; + RecipesConfig recipes; + ShipsConfig ships; + StationsConfig stations; +}; diff --git a/src/lib/config/RecipesConfig.h b/src/lib/config/RecipesConfig.h new file mode 100644 index 0000000..641f630 --- /dev/null +++ b/src/lib/config/RecipesConfig.h @@ -0,0 +1,40 @@ +#pragma once + +#include +#include +#include + +#include "BuildingType.h" + +// One entry in [[recipe]].inputs — amount units of a named item consumed per +// production cycle (REQ-MAT-CYCLE). +struct RecipeIngredient +{ + std::string item; + int amount; +}; + +// One entry in [[recipe]].outputs. For reprocessing_plant recipes, probability +// is populated and outputs are rolled with weighted pick at cycle start +// (REQ-BLD-REPROCESSING, REQ-MAT-CYCLE). For other buildings, probability is +// std::nullopt and all outputs are produced on every cycle. +struct RecipeOutput +{ + std::string item; + int amount; + std::optional probability; +}; + +struct RecipeDef +{ + std::string id; // Unique recipe id; used by UI for selection. + BuildingType building; // Which BuildingType can run this recipe. + std::vector inputs; + std::vector outputs; + double durationSeconds; +}; + +struct RecipesConfig +{ + std::vector recipes; +}; diff --git a/src/lib/config/ShipsConfig.h b/src/lib/config/ShipsConfig.h new file mode 100644 index 0000000..39dc856 --- /dev/null +++ b/src/lib/config/ShipsConfig.h @@ -0,0 +1,84 @@ +#pragma once + +#include +#include +#include + +#include "Formula.h" +#include "RecipesConfig.h" // for RecipeIngredient + +// Build materials and initial per-blueprint production level +// (REQ-BLD-SHIPYARD, REQ-DEF-BLUEPRINT-DROP). +struct ShipBlueprint +{ + std::vector materials; + int playerProductionLevel; +}; + +// Wave scheduling cost (REQ-WAV-THREAT-COST). Ships with cost_formula that +// always evaluates to 0 are ineligible as wave picks. +struct ShipThreat +{ + Formula costFormula; +}; + +struct ShipHealth +{ + Formula hpFormula; // REQ-SHP-STATS +}; + +struct ShipMovement +{ + Formula speedFormula; // REQ-SHP-STATS, REQ-SHP-MOVEMENT +}; + +struct ShipCombat +{ + Formula damageFormula; + Formula attackRangeFormula; + Formula attackRateFormula; // shots per second +}; + +// Optional; present only on salvage ships (REQ-SHP-SALVAGE). +struct ShipSalvage +{ + double collectionRange; + int cargoCapacity; +}; + +// Optional; present only on repair ships (REQ-SHP-REPAIR). +struct ShipRepair +{ + Formula repairRateFormula; + Formula repairRangeFormula; +}; + +// Scrap dropped on destruction (REQ-RES-SCRAP-DROP). +struct ShipLoot +{ + int scrapDrop; +}; + +struct ShipDef +{ + std::string id; + bool availableFromStart; + + ShipBlueprint blueprint; + ShipThreat threat; + ShipHealth health; + ShipMovement movement; + ShipLoot loot; + + // Role-specific sections. A ship is a combat ship if combat is present, + // a salvage ship if salvage is present, etc. A ship may have multiple + // of these set (hybrid ships) once the behavior systems support it. + std::optional combat; + std::optional salvage; + std::optional repair; +}; + +struct ShipsConfig +{ + std::vector ships; +}; diff --git a/src/lib/config/StationsConfig.h b/src/lib/config/StationsConfig.h new file mode 100644 index 0000000..39c4695 --- /dev/null +++ b/src/lib/config/StationsConfig.h @@ -0,0 +1,45 @@ +#pragma once + +#include +#include + +#include "Formula.h" + +// REQ-HQ-STATS. HP may become a formula of player level later; for now the +// requirement only lists hp so we store a formula that callers evaluate at 0. +struct HqConfig +{ + std::vector surfaceMask; + Formula hpFormula; +}; + +// REQ-DEF-PLAYER-FIRE. Stats are formulas of a fixed station level. +struct PlayerStationConfig +{ + std::vector surfaceMask; + int level; + Formula hpFormula; + Formula damageFormula; + Formula rangeFormula; + Formula fireRateFormula; // shots per second + Formula scrapDropFormula; +}; + +// REQ-PSH-STATION-STATS. Stats are formulas of the station generation level, +// which increments each time a new set is placed. +struct EnemyStationConfig +{ + std::vector surfaceMask; + Formula hpFormula; + Formula damageFormula; + Formula rangeFormula; + Formula fireRateFormula; + Formula scrapDropFormula; +}; + +struct StationsConfig +{ + HqConfig hq; + PlayerStationConfig playerStation; + EnemyStationConfig enemyStation; +}; diff --git a/src/lib/config/WorldConfig.h b/src/lib/config/WorldConfig.h new file mode 100644 index 0000000..0854378 --- /dev/null +++ b/src/lib/config/WorldConfig.h @@ -0,0 +1,48 @@ +#pragma once + +#include "Formula.h" + +// Region widths are in tiles (REQ-GW-REGIONS). +struct WorldRegions +{ + int asteroidWidth; + int playerBufferWidth; + int contestZoneWidth; + int enemyBufferWidth; +}; + +// Asteroid expansion (REQ-EXP-UNLOCK, REQ-EXP-COST). +struct WorldExpansion +{ + int columnsPerExpansion; + int costBuildingBlocks; +}; + +// Push scaling (REQ-PSH-*). +struct WorldPush +{ + int pushExpandColumns; + double scalingFactor; +}; + +// Wave scheduling (REQ-WAV-*). +struct WorldWaves +{ + Formula threatRateFormula; // threat/s as a function of elapsed game-time seconds + Formula shipLevelFormula; // enemy ship level as a function of elapsed game-time seconds + double gapMinSeconds; + double gapMaxSeconds; + double spawnDurationSeconds; +}; + +struct WorldConfig +{ + int heightTiles; // REQ-GW-HEIGHT + int refundPercentage; // REQ-BLD-DEMOLISH + double scrapDespawnSeconds; // REQ-RES-SCRAP-DROP + + WorldRegions regions; + WorldExpansion expansion; + WorldPush push; + WorldWaves waves; +}; diff --git a/src/lib/core/BlueprintDropEvent.h b/src/lib/core/BlueprintDropEvent.h new file mode 100644 index 0000000..d5dff06 --- /dev/null +++ b/src/lib/core/BlueprintDropEvent.h @@ -0,0 +1,14 @@ +#pragma once + +#include + +// Emitted in tick step 9 (Deaths & loot) when a destroyed enemy-defence-station +// set awards a blueprint (REQ-DEF-BLUEPRINT-DROP). The UI renders a toast +// (REQ-UI-BLUEPRINT-TOAST); wasNewUnlock chooses between the "unlocked" and +// "level -> N" wording. +struct BlueprintDropEvent +{ + std::string blueprintId; // matches ShipDef::id in the config. + int newLevel; + bool wasNewUnlock; +}; diff --git a/src/lib/core/BuildingType.cpp b/src/lib/core/BuildingType.cpp new file mode 100644 index 0000000..b8ce802 --- /dev/null +++ b/src/lib/core/BuildingType.cpp @@ -0,0 +1,36 @@ +#include "BuildingType.h" + +std::optional parseBuildingType(const std::string& id) +{ + if (id == "miner") { return BuildingType::Miner; } + if (id == "smelter") { return BuildingType::Smelter; } + if (id == "assembler") { return BuildingType::Assembler; } + if (id == "reprocessing_plant") { return BuildingType::ReprocessingPlant; } + if (id == "shipyard") { return BuildingType::Shipyard; } + if (id == "salvage_bay") { return BuildingType::SalvageBay; } + if (id == "belt") { return BuildingType::Belt; } + if (id == "splitter") { return BuildingType::Splitter; } + if (id == "hq") { return BuildingType::Hq; } + if (id == "player_defence_station") { return BuildingType::PlayerDefenceStation; } + if (id == "enemy_defence_station") { return BuildingType::EnemyDefenceStation; } + return std::nullopt; +} + +std::string buildingTypeId(BuildingType type) +{ + switch (type) + { + case BuildingType::Miner: return "miner"; + case BuildingType::Smelter: return "smelter"; + case BuildingType::Assembler: return "assembler"; + case BuildingType::ReprocessingPlant: return "reprocessing_plant"; + case BuildingType::Shipyard: return "shipyard"; + case BuildingType::SalvageBay: return "salvage_bay"; + case BuildingType::Belt: return "belt"; + case BuildingType::Splitter: return "splitter"; + case BuildingType::Hq: return "hq"; + case BuildingType::PlayerDefenceStation: return "player_defence_station"; + case BuildingType::EnemyDefenceStation: return "enemy_defence_station"; + } + return ""; +} diff --git a/src/lib/core/BuildingType.h b/src/lib/core/BuildingType.h new file mode 100644 index 0000000..93da8ea --- /dev/null +++ b/src/lib/core/BuildingType.h @@ -0,0 +1,29 @@ +#pragma once + +#include +#include + +// All building types defined in requirements.md. Belts and splitters share the +// enum for cost/construction/placement/visuals lookup, but their runtime data +// lives in the belt subsystem rather than in Building instances. +enum class BuildingType +{ + Miner, + Smelter, + Assembler, + ReprocessingPlant, + Shipyard, + SalvageBay, + Belt, + Splitter, + Hq, + PlayerDefenceStation, + EnemyDefenceStation, +}; + +// Maps a config id string (e.g. "miner", "reprocessing_plant") to a +// BuildingType. Returns std::nullopt for unknown ids. +std::optional parseBuildingType(const std::string& id); + +// Canonical id string for a BuildingType. The inverse of parseBuildingType. +std::string buildingTypeId(BuildingType type); diff --git a/src/lib/core/CMakeLists.txt b/src/lib/core/CMakeLists.txt new file mode 100644 index 0000000..a675998 --- /dev/null +++ b/src/lib/core/CMakeLists.txt @@ -0,0 +1,26 @@ +SET(HDRS + ${HDRS} + ${CMAKE_CURRENT_SOURCE_DIR}/Tick.h + ${CMAKE_CURRENT_SOURCE_DIR}/EntityId.h + ${CMAKE_CURRENT_SOURCE_DIR}/Rotation.h + ${CMAKE_CURRENT_SOURCE_DIR}/BuildingType.h + ${CMAKE_CURRENT_SOURCE_DIR}/ItemType.h + ${CMAKE_CURRENT_SOURCE_DIR}/Item.h + ${CMAKE_CURRENT_SOURCE_DIR}/Port.h + ${CMAKE_CURRENT_SOURCE_DIR}/MovementIntent.h + ${CMAKE_CURRENT_SOURCE_DIR}/FireEvent.h + ${CMAKE_CURRENT_SOURCE_DIR}/BlueprintDropEvent.h + PARENT_SCOPE +) + +SET(SRCS + ${SRCS} + ${CMAKE_CURRENT_SOURCE_DIR}/BuildingType.cpp + PARENT_SCOPE +) + +set(LIB_INCLUDE_PATH + ${LIB_INCLUDE_PATH} + ${CMAKE_CURRENT_SOURCE_DIR} + PARENT_SCOPE +) diff --git a/src/lib/core/EntityId.h b/src/lib/core/EntityId.h new file mode 100644 index 0000000..bd034c5 --- /dev/null +++ b/src/lib/core/EntityId.h @@ -0,0 +1,9 @@ +#pragma once + +// Canonical reference to every targetable entity in the simulation: ships, +// scrap drops, and buildings (including HQ and defence stations). +// Ids are allocated centrally by the Simulation, strictly increasing, never +// reused. 0 is reserved as the invalid id. +using EntityId = long long; + +constexpr EntityId kInvalidEntityId = 0; diff --git a/src/lib/core/FireEvent.h b/src/lib/core/FireEvent.h new file mode 100644 index 0000000..43cf0ed --- /dev/null +++ b/src/lib/core/FireEvent.h @@ -0,0 +1,14 @@ +#pragma once + +#include "EntityId.h" +#include "Tick.h" + +// Transient record emitted each time a weapon fires (REQ-SHP-FIRING, +// REQ-SHP-FIRING-BEAM). Buffered in a sim-owned queue and drained by the +// renderer each frame to draw the 0.3-second laser beam. +struct FireEvent +{ + EntityId shooter; + EntityId target; + Tick emittedAt; +}; diff --git a/src/lib/core/Item.h b/src/lib/core/Item.h new file mode 100644 index 0000000..6786232 --- /dev/null +++ b/src/lib/core/Item.h @@ -0,0 +1,10 @@ +#pragma once + +#include "ItemType.h" + +// Items on belts have no persistent identity across ticks; see +// architecture.md "Belt Subsystem". +struct Item +{ + ItemType type; +}; diff --git a/src/lib/core/ItemType.h b/src/lib/core/ItemType.h new file mode 100644 index 0000000..38c4127 --- /dev/null +++ b/src/lib/core/ItemType.h @@ -0,0 +1,29 @@ +#pragma once + +#include + +// Opaque tagged id of every transportable material (ores, ingots, +// intermediates, building_blocks, scrap). Defined in config; the simulation +// does not enumerate item types. +// +// Wrapped in a struct (rather than a bare std::string typedef) so that function +// signatures make the semantic intent explicit. +struct ItemType +{ + std::string id; +}; + +inline bool operator==(const ItemType& a, const ItemType& b) +{ + return a.id == b.id; +} + +inline bool operator!=(const ItemType& a, const ItemType& b) +{ + return a.id != b.id; +} + +inline bool operator<(const ItemType& a, const ItemType& b) +{ + return a.id < b.id; +} diff --git a/src/lib/core/MovementIntent.h b/src/lib/core/MovementIntent.h new file mode 100644 index 0000000..f319273 --- /dev/null +++ b/src/lib/core/MovementIntent.h @@ -0,0 +1,12 @@ +#pragma once + +#include + +// A ship-behavior system writes this each tick before movement runs; the +// highest-priority write wins. Priority order is fixed globally — see +// architecture.md "Movement Arbitration". +struct MovementIntent +{ + int priority; + QVector2D target; +}; diff --git a/src/lib/core/Port.h b/src/lib/core/Port.h new file mode 100644 index 0000000..7fc024f --- /dev/null +++ b/src/lib/core/Port.h @@ -0,0 +1,13 @@ +#pragma once + +#include + +#include "Rotation.h" + +// Identifies a belt-adjacent cell and the direction of flow across it. Used by +// the belt subsystem's push/pull interface. +struct Port +{ + QPoint tile; + Rotation direction; +}; diff --git a/src/lib/core/Rotation.h b/src/lib/core/Rotation.h new file mode 100644 index 0000000..3d4f29c --- /dev/null +++ b/src/lib/core/Rotation.h @@ -0,0 +1,12 @@ +#pragma once + +// Rotation applied to a building's surface_mask when placed. Also the direction +// of an output port or belt flow. +// North = -Y (up), East = +X (right), South = +Y (down), West = -X (left). +enum class Rotation +{ + North, + East, + South, + West, +}; diff --git a/src/lib/core/Tick.h b/src/lib/core/Tick.h new file mode 100644 index 0000000..faab920 --- /dev/null +++ b/src/lib/core/Tick.h @@ -0,0 +1,23 @@ +#pragma once + +// Discrete simulation time, measured in ticks since t=0. +using Tick = long long; + +// Fixed simulation tick rate. See architecture.md "Fixed-Timestep Tick-Based Simulation". +constexpr int kTickRateHz = 30; +constexpr double kTickDurationMs = 1000.0 / kTickRateHz; +constexpr double kTickDurationSeconds = 1.0 / kTickRateHz; + +// Converts a wall-clock duration (in seconds, as it appears in config TOML) to +// an integer tick count. Rounds to nearest to avoid systematic drift from +// repeated conversions. +constexpr Tick secondsToTicks(double seconds) +{ + return static_cast(seconds * kTickRateHz + 0.5); +} + +// Inverse of secondsToTicks; useful for logging and UI display. +constexpr double ticksToSeconds(Tick ticks) +{ + return static_cast(ticks) / kTickRateHz; +} diff --git a/src/test/CMakeLists.txt b/src/test/CMakeLists.txt index e39dcaf..92de5db 100644 --- a/src/test/CMakeLists.txt +++ b/src/test/CMakeLists.txt @@ -1,6 +1,8 @@ add_files( - TEST_FILES + TEST_FILES - test.cpp + test.cpp + FormulaTest.cpp + ConfigLoaderTest.cpp ) diff --git a/src/test/ConfigLoaderTest.cpp b/src/test/ConfigLoaderTest.cpp new file mode 100644 index 0000000..9c4599c --- /dev/null +++ b/src/test/ConfigLoaderTest.cpp @@ -0,0 +1,318 @@ +#include "catch.hpp" + +#include +#include +#include +#include +#include +#include + +#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(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 = DOTA_FACTORY_CONFIG_DIR; + const GameConfig cfg = ConfigLoader::loadFromDirectory(configDir); + + // world.toml + REQUIRE(cfg.world.heightTiles == 60); + REQUIRE(cfg.world.refundPercentage == 75); + REQUIRE(cfg.world.regions.asteroidWidth == 40); + REQUIRE(cfg.world.regions.playerBufferWidth == 10); + REQUIRE(cfg.world.regions.enemyBufferWidth == 15); + REQUIRE(cfg.world.expansion.columnsPerExpansion == 10); + REQUIRE(cfg.world.push.scalingFactor == Approx(1.2)); + + // Spot-check that a config-derived formula computes as expected. + // threat_rate_formula = "1*x - 30": zero at x=30, 30 at x=60. + REQUIRE(cfg.world.waves.threatRateFormula.evaluate(30.0) == Approx(0.0)); + REQUIRE(cfg.world.waves.threatRateFormula.evaluate(60.0) == Approx(30.0)); + + // 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); + + // 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 a combat section; 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(interceptorIt->combat.has_value()); + REQUIRE_FALSE(interceptorIt->salvage.has_value()); + REQUIRE_FALSE(interceptorIt->repair.has_value()); + REQUIRE(interceptorIt->combat->damageFormula.evaluate(5.0) == Approx(20.0)); // "10 + 2*x" + + 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_FALSE(salvageShipIt->combat.has_value()); + REQUIRE(salvageShipIt->salvage.has_value()); + REQUIRE(salvageShipIt->salvage->cargoCapacity == 10); + + // 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 + +[regions] +asteroid_width = 40 +player_buffer_width = 10 +contest_zone_width = 30 +# enemy_buffer_width intentionally missing + +[expansion] +columns_per_expansion = 10 +cost_building_blocks = 200 + +[push] +push_expand_columns = 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") != 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 + +[regions] +asteroid_width = 40 +player_buffer_width = 10 +contest_zone_width = 30 +enemy_buffer_width = 15 + +[expansion] +columns_per_expansion = 10 +cost_building_blocks = 200 + +[push] +push_expand_columns = 20 +scaling_factor = 1.2 + +[waves] +threat_rate_formula = "1 * + x" +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("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 + +[regions] +asteroid_width = 40 +player_buffer_width = 10 +contest_zone_width = 30 +enemy_buffer_width = 15 + +[expansion] +columns_per_expansion = 10 +cost_building_blocks = 200 + +[push] +push_expand_columns = 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); +} diff --git a/src/test/FormulaTest.cpp b/src/test/FormulaTest.cpp new file mode 100644 index 0000000..ed5344b --- /dev/null +++ b/src/test/FormulaTest.cpp @@ -0,0 +1,75 @@ +#include "catch.hpp" + +#include + +#include "Formula.h" + +TEST_CASE("Formula compiles a constant expression", "[formula]") +{ + const Formula f = Formula::compile("42"); + + REQUIRE(f.isValid()); + REQUIRE(f.evaluate(0.0) == Approx(42.0)); + REQUIRE(f.evaluate(100.0) == Approx(42.0)); +} + +TEST_CASE("Formula evaluates a linear expression in x", "[formula]") +{ + // Matches world.toml [waves].threat_rate_formula default. + const Formula f = Formula::compile("1*x - 30"); + + REQUIRE(f.evaluate(0.0) == Approx(-30.0)); + REQUIRE(f.evaluate(30.0) == Approx(0.0)); + REQUIRE(f.evaluate(60.0) == Approx(30.0)); +} + +TEST_CASE("Formula evaluates a polynomial in x", "[formula]") +{ + const Formula f = Formula::compile("2 + 3*x*x"); + + REQUIRE(f.evaluate(0.0) == Approx(2.0)); + REQUIRE(f.evaluate(2.0) == Approx(14.0)); + REQUIRE(f.evaluate(4.0) == Approx(50.0)); +} + +TEST_CASE("Formula retains its source string", "[formula]") +{ + const std::string source = "10 + x / 5"; + const Formula f = Formula::compile(source); + + REQUIRE(f.source() == source); +} + +TEST_CASE("Formula throws on malformed source", "[formula]") +{ + REQUIRE_THROWS_AS(Formula::compile("1 * + x"), std::runtime_error); + REQUIRE_THROWS_AS(Formula::compile(""), std::runtime_error); + REQUIRE_THROWS_AS(Formula::compile("unknown_var"), std::runtime_error); +} + +TEST_CASE("Formula survives move construction", "[formula]") +{ + // tinyexpr bakes the bound variable's address into the compiled tree. + // Formula keeps x on the heap so that address stays valid across moves. + Formula original = Formula::compile("x * 2"); + Formula moved(std::move(original)); + + REQUIRE(moved.evaluate(21.0) == Approx(42.0)); +} + +TEST_CASE("Formula survives move assignment", "[formula]") +{ + Formula a = Formula::compile("x + 1"); + Formula b = Formula::compile("x + 100"); + + b = std::move(a); + + REQUIRE(b.evaluate(5.0) == Approx(6.0)); +} + +TEST_CASE("Default-constructed Formula is invalid and throws on evaluate", "[formula]") +{ + const Formula f; + REQUIRE_FALSE(f.isValid()); + REQUIRE_THROWS_AS(f.evaluate(0.0), std::runtime_error); +} diff --git a/src/test/test.cpp b/src/test/test.cpp index 2af91cc..e355ab0 100644 --- a/src/test/test.cpp +++ b/src/test/test.cpp @@ -1,3 +1,3 @@ #define CATCH_CONFIG_MAIN // This tells Catch to provide a main() function -#include "catch/catch.hpp" +#include "catch.hpp"