#include "ConfigLoader.h" #include #include #include #include #include #include #include #include "toml.hpp" #include "Rotation.h" #include "ShipLayout.h" 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()); } } Rotation parseRotationString(const std::string& s) { if (s == "east") { return Rotation::East; } if (s == "south") { return Rotation::South; } if (s == "west") { return Rotation::West; } return Rotation::North; } std::vector parsePlacedModules(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) { continue; } toml::table& mt = const_cast(*t); const std::optional type = mt["type"].value(); const std::optional x = mt["x"].value(); const std::optional y = mt["y"].value(); const std::optional rot = mt["rotation"].value(); if (!type || !x || !y || !rot) { continue; } PlacedModule pm; pm.moduleId = *type; pm.position = QPoint(static_cast(*x), static_cast(*y)); pm.rotation = parseRotationString(*rot); result.push_back(std::move(pm)); } return result; } } // 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.startingBuildingBlocks = static_cast(requireInt(tbl["world"]["starting_building_blocks"], file, "world.starting_building_blocks")); cfg.scrapDespawnSeconds = requireDouble(tbl["world"]["scrap_despawn_seconds"], file, "world.scrap_despawn_seconds"); cfg.tileSize_m = requireDouble(tbl["world"]["tile_size_m"], file, "world.tile_size_m"); cfg.beltSpeed_tps = requireDouble(tbl["world"]["belt_speed_mps"], file, "world.belt_speed_mps") / cfg.tileSize_m; cfg.tunnelMaxDistance_tiles = static_cast(requireInt(tbl["world"]["tunnel_max_distance_tiles"], file, "world.tunnel_max_distance_tiles")); cfg.departureIntervalSeconds = requireDouble(tbl["world"]["departure_interval_seconds"], file, "world.departure_interval_seconds"); cfg.regions.asteroidWidth_tiles = static_cast(requireInt(tbl["regions"]["asteroid_width_tiles"], file, "regions.asteroid_width_tiles")); cfg.regions.playerBufferWidth_tiles = static_cast(requireInt(tbl["regions"]["player_buffer_width_tiles"], file, "regions.player_buffer_width_tiles")); cfg.regions.contestZoneWidth_tiles = static_cast(requireInt(tbl["regions"]["contest_zone_width_tiles"], file, "regions.contest_zone_width_tiles")); cfg.regions.enemyBufferWidth_tiles = static_cast(requireInt(tbl["regions"]["enemy_buffer_width_tiles"], file, "regions.enemy_buffer_width_tiles")); cfg.expansion.columnsPerExpansion_tiles = static_cast(requireInt(tbl["expansion"]["columns_per_expansion_tiles"], file, "expansion.columns_per_expansion_tiles")); cfg.expansion.costBuildingBlocks = static_cast(requireInt(tbl["expansion"]["cost_building_blocks"], file, "expansion.cost_building_blocks")); cfg.push.pushExpandColumns_tiles = static_cast(requireInt(tbl["push"]["push_expand_columns_tiles"], file, "push.push_expand_columns_tiles")); cfg.push.bossAdvanceSeconds = requireDouble(tbl["push"]["boss_advance_seconds"], file, "push.boss_advance_seconds"); 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"); cfg.waves.bossCountdownSeconds = requireDouble(tbl["waves"]["boss_countdown_seconds"], file, "waves.boss_countdown_seconds"); cfg.waves.bossThreatDurationSeconds = requireDouble(tbl["waves"]["boss_threat_duration_seconds"], file, "waves.boss_threat_duration_seconds"); cfg.waves.bossQuietBeforeSeconds = requireDouble(tbl["waves"]["boss_quiet_before_seconds"], file, "waves.boss_quiet_before_seconds"); cfg.waves.bossQuietAfterSeconds = requireDouble(tbl["waves"]["boss_quiet_after_seconds"], file, "waves.boss_quiet_after_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"); def.layout = requireStringArray(mt["layout"], file, elemPath + ".layout"); // Schematic { const std::string bpPath = elemPath + ".schematic"; const toml::table& bpTable = requireTable(mt["schematic"], file, bpPath); toml::table& bpMt = const_cast(bpTable); const toml::array& materials = requireArray(bpMt["materials"], file, bpPath + ".materials"); def.schematic.materials = parseIngredients(materials, file, bpPath + ".materials"); def.schematic.playerProductionLevel = static_cast(requireInt( bpMt["player_production_level"], file, bpPath + ".player_production_level")); def.schematic.productionTimeSeconds = requireDouble( bpMt["production_time_seconds"], file, bpPath + ".production_time_seconds"); } // 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_mps_formula"], file, mPath + ".speed_mps_formula"); def.movement.mainAccelerationFormula = requireFormula(mMt["main_acceleration_mpss_formula"], file, mPath + ".main_acceleration_mpss_formula"); def.movement.maneuveringAccelerationFormula = requireFormula(mMt["maneuvering_acceleration_mpss_formula"], file, mPath + ".maneuvering_acceleration_mpss_formula"); def.movement.angularAccelerationFormula = requireFormula(mMt["angular_acceleration_radpss_formula"], file, mPath + ".angular_acceleration_radpss_formula"); def.movement.maxRotationSpeedFormula = requireFormula(mMt["max_rotation_speed_radps_formula"], file, mPath + ".max_rotation_speed_radps_formula"); } // Sensor { const std::string snsPath = elemPath + ".sensor"; const toml::table& snsTable = requireTable(mt["sensor"], file, snsPath); toml::table& snsMt = const_cast(snsTable); def.sensor.sensorRangeFormula = requireFormula(snsMt["sensor_range_m_formula"], file, snsPath + ".sensor_range_m_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: default_modules (REQ-WAV-DEFAULT-MODULES) if (mt.contains("default_modules")) { const toml::array& modArr = requireArray(mt["default_modules"], file, elemPath + ".default_modules"); def.defaultModules = parsePlacedModules(modArr, file, elemPath + ".default_modules"); } 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_m_formula"], file, p + ".range_m_formula"); cfg.playerStation.fireRateFormula = requireFormula(tbl[p]["fire_rate_hz_formula"], file, p + ".fire_rate_hz_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_m_formula"], file, p + ".range_m_formula"); cfg.enemyStation.fireRateFormula = requireFormula(tbl[p]["fire_rate_hz_formula"], file, p + ".fire_rate_hz_formula"); cfg.enemyStation.scrapDropFormula = requireFormula(tbl[p]["scrap_drop_formula"], file, p + ".scrap_drop_formula"); } return cfg; } // Known category→stat mappings for module stat modifier discovery. // addedKeySuffix: unit suffix appended before "_formula" for additive modifier keys only. // Multiplicative modifier keys are always dimensionless and carry no suffix. struct StatEntry { const char* category; const char* stat; const char* addedKeySuffix; }; static const StatEntry kKnownStats[] = { {"health", "hp", ""}, {"movement", "speed", "_mps"}, {"sensor", "sensor_range", "_m"}, {"weapon", "damage", ""}, {"weapon", "attack_range", "_m"}, {"weapon", "attack_rate", "_hz"}, {"salvage", "collection_range", "_m"}, {"salvage", "cargo_capacity", ""}, {"salvage", "collection_rate", "_hz"}, {"repair", "repair_rate", "_hz"}, {"repair", "repair_range", "_m"}, }; ModulesConfig ConfigLoader::loadModules(const std::string& path) { const std::string file = "modules.toml"; toml::table tbl = parseFile(path, file); ModulesConfig cfg; if (!tbl.contains("module")) { return cfg; } const toml::array& arr = requireArray(tbl["module"], file, "module"); for (std::size_t i = 0; i < arr.size(); ++i) { const std::string elemPath = "module[" + 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); ModuleDef def; def.id = requireString(mt["id"], file, elemPath + ".id"); def.surfaceMask = requireStringArray(mt["surface_mask"], file, elemPath + ".surface_mask"); def.playerProductionLevel = static_cast(requireInt( mt["player_production_level"], file, elemPath + ".player_production_level")); def.productionTimeSeconds = requireDouble( mt["production_time_seconds"], file, elemPath + ".production_time_seconds"); def.threatCost = requireDouble(mt["threat_cost"], file, elemPath + ".threat_cost"); def.fillColor = requireString(mt["fill_color"], file, elemPath + ".fill_color"); def.glyph = requireString(mt["glyph"], file, elemPath + ".glyph"); // Materials { const toml::array& materials = requireArray(mt["materials"], file, elemPath + ".materials"); def.materials = parseIngredients(materials, file, elemPath + ".materials"); } // Stat modifiers from [module.] sub-tables for (const StatEntry& se : kKnownStats) { if (!mt.contains(se.category)) { continue; } const toml::table& catTable = requireTable(mt[se.category], file, elemPath + "." + se.category); toml::table& catMt = const_cast(catTable); const std::string addedKey = std::string("added_") + se.stat + se.addedKeySuffix + "_formula"; const std::string multipliedKey = std::string("multiplied_") + se.stat + "_formula"; if (catMt.contains(addedKey)) { ModuleStatModifier mod; mod.stat = se.stat; mod.modifierType = "additive"; mod.formula = requireFormula(catMt[addedKey], file, elemPath + "." + se.category + "." + addedKey); def.statModifiers.push_back(std::move(mod)); } if (catMt.contains(multipliedKey)) { ModuleStatModifier mod; mod.stat = se.stat; mod.modifierType = "multiplicative"; mod.formula = requireFormula(catMt[multipliedKey], file, elemPath + "." + se.category + "." + multipliedKey); def.statModifiers.push_back(std::move(mod)); } } // Weapon capability section: [module.weapon] with base stat formulas if (mt.contains("weapon")) { const std::string wPath = elemPath + ".weapon"; const toml::table& wTable = requireTable(mt["weapon"], file, wPath); toml::table& wMt = const_cast(wTable); if (wMt.contains("damage_formula") || wMt.contains("attack_range_m_formula") || wMt.contains("attack_rate_hz_formula")) { ModuleWeaponCapability cap; cap.damageFormula = requireFormula(wMt["damage_formula"], file, wPath + ".damage_formula"); cap.attackRangeFormula = requireFormula(wMt["attack_range_m_formula"], file, wPath + ".attack_range_m_formula"); cap.attackRateFormula = requireFormula(wMt["attack_rate_hz_formula"], file, wPath + ".attack_rate_hz_formula"); def.weaponCapability = std::move(cap); } } // Salvage capability section: [module.salvage] with base stat formulas 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); if (sMt.contains("collection_range_m_formula") || sMt.contains("cargo_capacity_formula") || sMt.contains("collection_rate_hz_formula")) { ModuleSalvageCapability cap; cap.collectionRangeFormula = requireFormula(sMt["collection_range_m_formula"], file, sPath + ".collection_range_m_formula"); cap.cargoCapacityFormula = requireFormula(sMt["cargo_capacity_formula"], file, sPath + ".cargo_capacity_formula"); cap.collectionRateFormula = requireFormula(sMt["collection_rate_hz_formula"], file, sPath + ".collection_rate_hz_formula"); def.salvageCapability = std::move(cap); } } // Repair capability section: [module.repair] with base stat formulas 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); if (rMt.contains("repair_rate_hz_formula") || rMt.contains("repair_range_m_formula")) { ModuleRepairCapability cap; cap.repairRateFormula = requireFormula(rMt["repair_rate_hz_formula"], file, rPath + ".repair_rate_hz_formula"); cap.repairRangeFormula = requireFormula(rMt["repair_range_m_formula"], file, rPath + ".repair_range_m_formula"); def.repairCapability = std::move(cap); } } cfg.modules.push_back(std::move(def)); } 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"); cfg.modules = loadModules(configDir + "/modules.toml"); return cfg; }