diff --git a/bin/test/data/config/modules.toml b/bin/test/data/config/modules.toml index 1ab756a..6831930 100644 --- a/bin/test/data/config/modules.toml +++ b/bin/test/data/config/modules.toml @@ -80,3 +80,58 @@ glyph = "Rp" [module.repair] repair_rate_hz_formula = "5 + x" repair_range_m_formula = "800" + +[[module]] +id = "weapon_primer" +surface_mask = ["O"] +materials = [{item = "iron_ingot", amount = 1}] +player_production_level = 1 +production_time_seconds = 4 +threat_cost = 1.0 +fill_color = "#FF4040" +glyph = "Wp" + +[module.weapon] +multiplied_attack_rate_hz_formula = "1.2" + +[[module]] +id = "weapon_stabilizer" +surface_mask = ["O"] +materials = [{item = "iron_ingot", amount = 1}] +player_production_level = 1 +production_time_seconds = 4 +threat_cost = 1.0 +fill_color = "#FF4040" +glyph = "Ws" + +[module.weapon] +multiplied_attack_range_m_formula = "1.5" +multiplied_attack_rate_hz_formula = "0.8" + +[[module]] +id = "afterburner" +surface_mask = ["O"] +materials = [{item = "iron_ingot", amount = 1}] +player_production_level = 1 +production_time_seconds = 2 +threat_cost = 1.0 +fill_color = "#40A0FF" +glyph = "Ab" + +[module.movement] +multiplied_speed_mps_formula = "1.6" +added_main_acceleration_mpss_formula = "60" + +[[module]] +id = "maneuvering_thrusters" +surface_mask = ["O"] +materials = [{item = "iron_ingot", amount = 1}] +player_production_level = 1 +production_time_seconds = 2 +threat_cost = 1.0 +fill_color = "#40A0FF" +glyph = "Mt" + +[module.movement] +multiplied_speed_mps_formula = "1.2" +added_maneuvering_acceleration_mpss_formula = "10" diff --git a/src/lib/config/ConfigLoader.cpp b/src/lib/config/ConfigLoader.cpp index 35a76bf..d49401c 100644 --- a/src/lib/config/ConfigLoader.cpp +++ b/src/lib/config/ConfigLoader.cpp @@ -530,17 +530,19 @@ struct StatEntry }; 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"}, + {"health", "hp", ""}, + {"movement", "speed", "_mps"}, + {"movement", "main_acceleration", "_mpss"}, + {"movement", "maneuvering_acceleration", "_mpss"}, + {"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) @@ -596,7 +598,7 @@ ModulesConfig ConfigLoader::loadModules(const std::string& path) 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"; + const std::string multipliedKey = std::string("multiplied_") + se.stat + se.addedKeySuffix + "_formula"; if (catMt.contains(addedKey)) { diff --git a/src/lib/ecs/system/ShipSystem.cpp b/src/lib/ecs/system/ShipSystem.cpp index d9572bc..d3762f7 100644 --- a/src/lib/ecs/system/ShipSystem.cpp +++ b/src/lib/ecs/system/ShipSystem.cpp @@ -210,7 +210,8 @@ entt::entity ShipSystem::spawn(const std::string& schematicId, int level, } // Range stat additive modifiers are expressed in metres in config; convert to tiles. - const double tileSizeD = static_cast(m_config.world.tileSize_m); + const double tileSizeD = static_cast(m_config.world.tileSize_m); + const double tickRateD = static_cast(kTickRateHz); const char* const kRangeStats[] = { "sensor_range", "attack_range", "collection_range", "repair_range" }; @@ -229,6 +230,23 @@ entt::entity ShipSystem::spawn(const std::string& schematicId, int level, } } + // Acceleration additive modifiers are in m/s² in config; convert to tiles/tick + // (same as the base spawn conversion: / tileSize / tickRate). + const char* const kAccelerationStats[] = { + "main_acceleration", "maneuvering_acceleration" + }; + for (const char* stat : kAccelerationStats) + { + for (std::map>* mods : allModMaps) + { + std::map>::iterator it = mods->find(stat); + if (it != mods->end()) + { + it->second.second /= tileSizeD * tickRateD; + } + } + } + // Helper: apply a modifier map to a float stat. auto applyMod = [](float& stat, const std::string& name, const std::map>& mods) diff --git a/src/lib/sim/ShipStatsCalculator.cpp b/src/lib/sim/ShipStatsCalculator.cpp index e0e9110..d9cb2b1 100644 --- a/src/lib/sim/ShipStatsCalculator.cpp +++ b/src/lib/sim/ShipStatsCalculator.cpp @@ -157,6 +157,22 @@ ShipStats calculateShipStats(const GameConfig& config, } } + // Acceleration additive modifiers are in m/s² in config; convert to tiles/s². + const char* const kAccelerationStats[] = { + "main_acceleration", "maneuvering_acceleration" + }; + for (const char* stat : kAccelerationStats) + { + for (std::map>* mods : allModMaps) + { + std::map>::iterator it = mods->find(stat); + if (it != mods->end()) + { + it->second.second /= tileSize; + } + } + } + auto applyMod = [](float& stat, const std::string& name, const std::map>& mods) { diff --git a/src/test/ModuleConfigTest.cpp b/src/test/ModuleConfigTest.cpp index ab7490e..e7f6885 100644 --- a/src/test/ModuleConfigTest.cpp +++ b/src/test/ModuleConfigTest.cpp @@ -8,6 +8,24 @@ static GameConfig loadConfig() return ConfigLoader::loadFromDirectory(CONFIG_DIR); } +static const ModuleDef* findModule(const GameConfig& cfg, const std::string& id) +{ + for (const ModuleDef& m : cfg.modules.modules) + { + if (m.id == id) { return &m; } + } + return nullptr; +} + +static const ModuleStatModifier* findModifier(const ModuleDef& def, const std::string& stat) +{ + for (const ModuleStatModifier& sm : def.statModifiers) + { + if (sm.stat == stat) { return &sm; } + } + return nullptr; +} + TEST_CASE("ConfigLoader: loadModules parses modules.toml", "[config][modules]") { const GameConfig cfg = loadConfig(); @@ -44,6 +62,72 @@ TEST_CASE("ConfigLoader: loadModules parses additive modifiers", "[config][modul CHECK(sensor.statModifiers[0].formula.evaluate(1.0) == Approx(100.0)); } +TEST_CASE("ConfigLoader: multiplicative modifier with unit suffix is parsed (weapon_primer)", "[config][modules]") +{ + const GameConfig cfg = loadConfig(); + const ModuleDef* primer = findModule(cfg, "weapon_primer"); + REQUIRE(primer != nullptr); + REQUIRE(primer->statModifiers.size() == 1); + const ModuleStatModifier* sm = findModifier(*primer, "attack_rate"); + REQUIRE(sm != nullptr); + CHECK(sm->modifierType == "multiplicative"); + CHECK(sm->formula.evaluate(1.0) == Approx(1.2)); +} + +TEST_CASE("ConfigLoader: weapon_stabilizer parses two multiplicative weapon modifiers", "[config][modules]") +{ + const GameConfig cfg = loadConfig(); + const ModuleDef* stab = findModule(cfg, "weapon_stabilizer"); + REQUIRE(stab != nullptr); + REQUIRE(stab->statModifiers.size() == 2); + + const ModuleStatModifier* rangeMod = findModifier(*stab, "attack_range"); + REQUIRE(rangeMod != nullptr); + CHECK(rangeMod->modifierType == "multiplicative"); + CHECK(rangeMod->formula.evaluate(1.0) == Approx(1.5)); + + const ModuleStatModifier* rateMod = findModifier(*stab, "attack_rate"); + REQUIRE(rateMod != nullptr); + CHECK(rateMod->modifierType == "multiplicative"); + CHECK(rateMod->formula.evaluate(1.0) == Approx(0.8)); +} + +TEST_CASE("ConfigLoader: afterburner parses multiplicative speed and additive main_acceleration", "[config][modules]") +{ + const GameConfig cfg = loadConfig(); + const ModuleDef* ab = findModule(cfg, "afterburner"); + REQUIRE(ab != nullptr); + REQUIRE(ab->statModifiers.size() == 2); + + const ModuleStatModifier* speedMod = findModifier(*ab, "speed"); + REQUIRE(speedMod != nullptr); + CHECK(speedMod->modifierType == "multiplicative"); + CHECK(speedMod->formula.evaluate(1.0) == Approx(1.6)); + + const ModuleStatModifier* accelMod = findModifier(*ab, "main_acceleration"); + REQUIRE(accelMod != nullptr); + CHECK(accelMod->modifierType == "additive"); + CHECK(accelMod->formula.evaluate(1.0) == Approx(60.0)); +} + +TEST_CASE("ConfigLoader: maneuvering_thrusters parses multiplicative speed and additive maneuvering_acceleration", "[config][modules]") +{ + const GameConfig cfg = loadConfig(); + const ModuleDef* mt = findModule(cfg, "maneuvering_thrusters"); + REQUIRE(mt != nullptr); + REQUIRE(mt->statModifiers.size() == 2); + + const ModuleStatModifier* speedMod = findModifier(*mt, "speed"); + REQUIRE(speedMod != nullptr); + CHECK(speedMod->modifierType == "multiplicative"); + CHECK(speedMod->formula.evaluate(1.0) == Approx(1.2)); + + const ModuleStatModifier* accelMod = findModifier(*mt, "maneuvering_acceleration"); + REQUIRE(accelMod != nullptr); + CHECK(accelMod->modifierType == "additive"); + CHECK(accelMod->formula.evaluate(1.0) == Approx(10.0)); +} + TEST_CASE("ConfigLoader: loadShips parses layout field", "[config][ships]") { const GameConfig cfg = loadConfig(); diff --git a/src/test/ShipModuleTest.cpp b/src/test/ShipModuleTest.cpp index 8263e38..2e58efa 100644 --- a/src/test/ShipModuleTest.cpp +++ b/src/test/ShipModuleTest.cpp @@ -4,17 +4,21 @@ #include "BuildingSystem.h" #include "BuildingType.h" #include "ConfigLoader.h" +#include "DynamicBodyComponent.h" #include "EntityAdmin.h" #include "GameConfig.h" #include "HealthComponent.h" #include "ItemType.h" +#include "ModuleOwnerComponent.h" #include "ModulesConfig.h" #include "Rotation.h" #include "SensorRangeComponent.h" #include "ShipLayout.h" +#include "ShipStatsCalculator.h" #include "ShipSystem.h" #include "Simulation.h" #include "Tick.h" +#include "WeaponComponent.h" static GameConfig loadConfig() { @@ -33,6 +37,17 @@ static const ShipDef* findSchematic(const GameConfig& cfg, const std::string& id return nullptr; } +static entt::entity findFirstWeaponChild(EntityAdmin& admin, entt::entity ship) +{ + entt::entity result = entt::null; + admin.forEach( + [&](entt::entity ce, const WeaponComponent&, const ModuleOwnerComponent& o) + { + if (o.owner == ship && result == entt::null) { result = ce; } + }); + return result; +} + static const BuildingDef* findShipyardDef(const GameConfig& cfg) { for (const BuildingDef& def : cfg.buildings.buildings) @@ -283,3 +298,219 @@ TEST_CASE("Shipyard: setRecipe clears ship layout", "[modules][shipyard]") REQUIRE(b2 != nullptr); CHECK_FALSE(b2->shipLayout.has_value()); } + +// --------------------------------------------------------------------------- +// Weapon modifier simulation tests +// --------------------------------------------------------------------------- + +TEST_CASE("Ship spawn: weapon_primer multiplies attack rate in simulation", "[modules]") +{ + Simulation sim(loadConfig(), 42); + const ShipDef* def = findSchematic(sim.config(), "interceptor"); + REQUIRE(def != nullptr); + + ShipLayoutConfig layout; + for (const std::string& id : {"laser_cannon", "weapon_primer"}) + { + PlacedModule pm; + pm.moduleId = id; + pm.position = QPoint(0, 0); + pm.rotation = Rotation::East; + layout.placedModules.push_back(pm); + } + + const entt::entity ship = sim.ships().spawn("interceptor", + def->schematic.playerProductionLevel, + QVector2D(5.0f, 5.0f), false, layout); + + const entt::entity weapon = findFirstWeaponChild(sim.admin(), ship); + REQUIRE(sim.admin().isValid(weapon)); + // base rate = 2.0 hz; weapon_primer multiplier = 1.2 → 2.4 hz + CHECK(sim.admin().get(weapon).fireRateHz == Approx(2.4f)); +} + +TEST_CASE("Ship spawn: weapon_stabilizer multiplies attack range in simulation", "[modules]") +{ + Simulation sim(loadConfig(), 42); + const ShipDef* def = findSchematic(sim.config(), "interceptor"); + REQUIRE(def != nullptr); + + const float tileSize = static_cast(sim.config().world.tileSize_m); + + ShipLayoutConfig layout; + for (const std::string& id : {"laser_cannon", "weapon_stabilizer"}) + { + PlacedModule pm; + pm.moduleId = id; + pm.position = QPoint(0, 0); + pm.rotation = Rotation::East; + layout.placedModules.push_back(pm); + } + + const entt::entity ship = sim.ships().spawn("interceptor", + def->schematic.playerProductionLevel, + QVector2D(5.0f, 5.0f), false, layout); + + const entt::entity weapon = findFirstWeaponChild(sim.admin(), ship); + REQUIRE(sim.admin().isValid(weapon)); + // base range = 50 m / tileSize = 5 tiles; weapon_stabilizer multiplier = 1.5 → 7.5 tiles + CHECK(sim.admin().get(weapon).range_tiles == Approx(50.0f / tileSize * 1.5f)); +} + +TEST_CASE("Ship spawn: afterburner additive main_acceleration is converted m/s² to tiles/tick", "[modules]") +{ + Simulation sim(loadConfig(), 42); + const ShipDef* def = findSchematic(sim.config(), "interceptor"); + REQUIRE(def != nullptr); + + const double x = static_cast(def->schematic.playerProductionLevel); + const float tileSize = static_cast(sim.config().world.tileSize_m); + const float tickRate = static_cast(kTickRateHz); + const float base_mpss = static_cast(def->movement.mainAccelerationFormula.evaluate(x)); + + ShipLayoutConfig layout; + PlacedModule pm; + pm.moduleId = "afterburner"; + pm.position = QPoint(0, 0); + pm.rotation = Rotation::East; + layout.placedModules.push_back(pm); + + const entt::entity e = sim.ships().spawn("interceptor", + def->schematic.playerProductionLevel, + QVector2D(5.0f, 5.0f), false, layout); + + // added_main_acceleration_mpss = 60; same conversion as base: / tileSize / tickRate + const float expected = (base_mpss + 60.0f) / tileSize / tickRate; + CHECK(sim.admin().get(e).mainAcceleration_tptt == Approx(expected)); +} + +TEST_CASE("Ship spawn: maneuvering_thrusters additive maneuvering_acceleration is converted m/s² to tiles/tick", "[modules]") +{ + Simulation sim(loadConfig(), 42); + const ShipDef* def = findSchematic(sim.config(), "interceptor"); + REQUIRE(def != nullptr); + + const double x = static_cast(def->schematic.playerProductionLevel); + const float tileSize = static_cast(sim.config().world.tileSize_m); + const float tickRate = static_cast(kTickRateHz); + const float base_mpss = static_cast(def->movement.maneuveringAccelerationFormula.evaluate(x)); + + ShipLayoutConfig layout; + PlacedModule pm; + pm.moduleId = "maneuvering_thrusters"; + pm.position = QPoint(0, 0); + pm.rotation = Rotation::East; + layout.placedModules.push_back(pm); + + const entt::entity e = sim.ships().spawn("interceptor", + def->schematic.playerProductionLevel, + QVector2D(5.0f, 5.0f), false, layout); + + // added_maneuvering_acceleration_mpss = 10; same conversion as base: / tileSize / tickRate + const float expected = (base_mpss + 10.0f) / tileSize / tickRate; + CHECK(sim.admin().get(e).maneuveringAcceleration_tptt == Approx(expected)); +} + +// --------------------------------------------------------------------------- +// Weapon modifier stats view tests (calculateShipStats) +// --------------------------------------------------------------------------- + +TEST_CASE("calculateShipStats: weapon_primer multiplies attack rate in stats view", "[modules]") +{ + const GameConfig cfg = loadConfig(); + const ShipDef* def = findSchematic(cfg, "interceptor"); + REQUIRE(def != nullptr); + + std::vector modules; + for (const std::string& id : {"laser_cannon", "weapon_primer"}) + { + PlacedModule pm; + pm.moduleId = id; + pm.position = QPoint(0, 0); + pm.rotation = Rotation::East; + modules.push_back(pm); + } + + const ShipStats stats = calculateShipStats(cfg, "interceptor", + def->schematic.playerProductionLevel, modules); + + REQUIRE(stats.weapons.has_value()); + // base: damage = 2, rate = 2.0 hz; weapon_primer multiplies rate by 1.2 → DPS = 2 * 2.4 = 4.8 + CHECK(stats.weapons->combinedDps == Approx(4.8f)); +} + +TEST_CASE("calculateShipStats: weapon_stabilizer multiplies attack range in stats view", "[modules]") +{ + const GameConfig cfg = loadConfig(); + const ShipDef* def = findSchematic(cfg, "interceptor"); + REQUIRE(def != nullptr); + + const float tileSize = static_cast(cfg.world.tileSize_m); + + std::vector modules; + for (const std::string& id : {"laser_cannon", "weapon_stabilizer"}) + { + PlacedModule pm; + pm.moduleId = id; + pm.position = QPoint(0, 0); + pm.rotation = Rotation::East; + modules.push_back(pm); + } + + const ShipStats stats = calculateShipStats(cfg, "interceptor", + def->schematic.playerProductionLevel, modules); + + REQUIRE(stats.weapons.has_value()); + // base range = 50 m / tileSize; weapon_stabilizer multiplier = 1.5 + CHECK(stats.weapons->maxRange_tiles == Approx(50.0f / tileSize * 1.5f)); +} + +TEST_CASE("calculateShipStats: afterburner additive main_acceleration is converted m/s² to tiles/s²", "[modules]") +{ + const GameConfig cfg = loadConfig(); + const ShipDef* def = findSchematic(cfg, "interceptor"); + REQUIRE(def != nullptr); + + const double x = static_cast(def->schematic.playerProductionLevel); + const float tileSize = static_cast(cfg.world.tileSize_m); + const float base_mpss = static_cast(def->movement.mainAccelerationFormula.evaluate(x)); + + std::vector modules; + PlacedModule pm; + pm.moduleId = "afterburner"; + pm.position = QPoint(0, 0); + pm.rotation = Rotation::East; + modules.push_back(pm); + + const ShipStats stats = calculateShipStats(cfg, "interceptor", + def->schematic.playerProductionLevel, modules); + + // added_main_acceleration_mpss = 60; converted to tiles/s²: / tileSize + const float expected = (base_mpss + 60.0f) / tileSize; + CHECK(stats.mainAcceleration_tpss == Approx(expected)); +} + +TEST_CASE("calculateShipStats: maneuvering_thrusters additive maneuvering_acceleration is converted m/s² to tiles/s²", "[modules]") +{ + const GameConfig cfg = loadConfig(); + const ShipDef* def = findSchematic(cfg, "interceptor"); + REQUIRE(def != nullptr); + + const double x = static_cast(def->schematic.playerProductionLevel); + const float tileSize = static_cast(cfg.world.tileSize_m); + const float base_mpss = static_cast(def->movement.maneuveringAccelerationFormula.evaluate(x)); + + std::vector modules; + PlacedModule pm; + pm.moduleId = "maneuvering_thrusters"; + pm.position = QPoint(0, 0); + pm.rotation = Rotation::East; + modules.push_back(pm); + + const ShipStats stats = calculateShipStats(cfg, "interceptor", + def->schematic.playerProductionLevel, modules); + + // added_maneuvering_acceleration_mpss = 10; converted to tiles/s²: / tileSize + const float expected = (base_mpss + 10.0f) / tileSize; + CHECK(stats.maneuveringAcceleration_tpss == Approx(expected)); +}