#include "catch.hpp" #include "Building.h" #include "BuildingSystem.h" #include "BuildingType.h" #include "ConfigLoader.h" #include "DynamicBodyComponent.h" #include "EntityAdmin.h" #include "FactionComponent.h" #include "GameConfig.h" #include "HealthComponent.h" #include "ItemType.h" #include "ModuleOwnerComponent.h" #include "ModulesConfig.h" #include "Rotation.h" #include "SensorRangeComponent.h" #include "ShipIdentityComponent.h" #include "ShipLayout.h" #include "ShipStatsCalculator.h" #include "ShipSystem.h" #include "Simulation.h" #include "SimulationTestAccess.h" #include "Tick.h" #include "WeaponComponent.h" #include "TestConfig.h" static const ShipDef* findSchematic(const GameConfig& cfg, const std::string& id) { for (const ShipDef& def : cfg.ships.ships) { if (def.id == id) { return &def; } } 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) { if (def.type == BuildingType::Shipyard) { return &def; } } return nullptr; } static BuildingId placeShipyard(Simulation& sim, const BuildingDef& yardDef) { return SimulationTestAccess::buildings(sim).placeImmediate( BuildingType::Shipyard, yardDef.surfaceMask, QPoint(0, 0), Rotation::East); } static void fillMaterials(Simulation& sim, BuildingId yardId, const ShipDef& def, const ShipLayoutConfig& layout) { SimulationTestAccess::buildings(sim).forEachBuilding([&](Building& b) { if (b.id != yardId) { return; } for (const RecipeIngredient& ing : def.schematic.materials) { b.inputBuffer.counts[ItemType{ing.item}] = ing.amount; } for (const PlacedModule& pm : layout.placedModules) { for (const ModuleDef& modDef : sim.getConfig().modules.modules) { if (modDef.id == pm.moduleId) { for (const RecipeIngredient& ing : modDef.materials) { b.inputBuffer.counts[ItemType{ing.item}] += ing.amount; } break; } } } }); } // --------------------------------------------------------------------------- // Ship stat modifiers // --------------------------------------------------------------------------- TEST_CASE("Ship spawn: no modules leaves base stats unchanged", "[modules]") { Simulation sim(loadTestConfig(), 42); const ShipDef* def = findSchematic(sim.getConfig(), "interceptor"); REQUIRE(def != nullptr); const float expectedHp = static_cast(def->health.hp); const entt::entity e = sim.getShips().spawn("interceptor", QVector2D(5.0f, 5.0f), false, std::nullopt); REQUIRE(sim.getAdmin().isValid(e)); CHECK(sim.getAdmin().get(e).maxHp == Approx(expectedHp)); } TEST_CASE("Ship spawn: multiplicative HP module applies correctly", "[modules]") { Simulation sim(loadTestConfig(), 42); const ShipDef* def = findSchematic(sim.getConfig(), "interceptor"); REQUIRE(def != nullptr); const float baseHp = static_cast(def->health.hp); ShipLayoutConfig layout; PlacedModule pm; pm.moduleId = "armor_plate"; pm.position = QPoint(0, 0); pm.rotation = Rotation::East; layout.placedModules.push_back(pm); const entt::entity e = sim.getShips().spawn("interceptor", QVector2D(5.0f, 5.0f), false, layout); REQUIRE(sim.getAdmin().isValid(e)); // armor_plate has multiplied_hp_formula = "1.5" // final = base * (1 + (1.5 - 1)) + 0 = base * 1.5 CHECK(sim.getAdmin().get(e).maxHp == Approx(baseHp * 1.5f)); CHECK(sim.getAdmin().get(e).hp == sim.getAdmin().get(e).maxHp); } TEST_CASE("Ship spawn: additive sensor module applies correctly", "[modules]") { Simulation sim(loadTestConfig(), 42); const ShipDef* def = findSchematic(sim.getConfig(), "interceptor"); REQUIRE(def != nullptr); const float tileSize = static_cast(sim.getConfig().world.tileSize_m); const float baseRange_tiles = static_cast(def->sensor.sensorRange_m) / tileSize; ShipLayoutConfig layout; PlacedModule pm; pm.moduleId = "sensor_booster"; pm.position = QPoint(0, 0); pm.rotation = Rotation::East; layout.placedModules.push_back(pm); const entt::entity e = sim.getShips().spawn("interceptor", QVector2D(5.0f, 5.0f), false, layout); REQUIRE(sim.getAdmin().isValid(e)); // sensor_booster has added_sensor_range_m_formula = "100" m → 100/10 = 10 tiles // final = baseRange_tiles * 1.0 + 10 = baseRange_tiles + 10 CHECK(sim.getAdmin().get(e).value_tiles == Approx(baseRange_tiles + 10.0f)); } TEST_CASE("Ship spawn: multiple modules stack correctly", "[modules]") { Simulation sim(loadTestConfig(), 42); const ShipDef* def = findSchematic(sim.getConfig(), "interceptor"); REQUIRE(def != nullptr); const float baseHp = static_cast(def->health.hp); ShipLayoutConfig layout; for (int i = 0; i < 2; ++i) { PlacedModule pm; pm.moduleId = "armor_plate"; pm.position = QPoint(i * 2, 0); pm.rotation = Rotation::East; layout.placedModules.push_back(pm); } const entt::entity e = sim.getShips().spawn("interceptor", QVector2D(5.0f, 5.0f), false, layout); REQUIRE(sim.getAdmin().isValid(e)); // Two armor_plates: each 1.5 multiplier // total_mult = 1 + (1.5 - 1) + (1.5 - 1) = 2.0 // final = base * 2.0 CHECK(sim.getAdmin().get(e).maxHp == Approx(baseHp * 2.0f)); } // --------------------------------------------------------------------------- // Shipyard module integration // --------------------------------------------------------------------------- TEST_CASE("Shipyard: setShipLayout reinitializes buffers with module materials", "[modules][shipyard]") { Simulation sim(loadTestConfig(), 42); const BuildingDef* yardDef = findShipyardDef(sim.getConfig()); REQUIRE(yardDef != nullptr); const BuildingId yardId = placeShipyard(sim, *yardDef); SimulationTestAccess::buildings(sim).setRecipe(yardId,"interceptor"); ShipLayoutConfig layout; PlacedModule pm; pm.moduleId = "armor_plate"; pm.position = QPoint(0, 0); pm.rotation = Rotation::East; layout.placedModules.push_back(pm); SimulationTestAccess::buildings(sim).setShipLayout(yardId, layout); const Building* b = sim.getBuildings().findBuilding(yardId); REQUIRE(b != nullptr); // armor_plate needs 2 iron_ingot; interceptor needs 3 iron_ingot + 1 circuit_board // Total iron_ingot = 5, buffer cap = 2 * 5 = 10 CHECK(b->inputBuffer.caps.at(ItemType{"iron_ingot"}) == 10); CHECK(b->inputBuffer.caps.at(ItemType{"circuit_board"}) == 2); } TEST_CASE("Shipyard: setShipLayout cancels in-progress production", "[modules][shipyard]") { Simulation sim(loadTestConfig(), 42); const ShipDef* def = findSchematic(sim.getConfig(), "interceptor"); REQUIRE(def != nullptr); const BuildingDef* yardDef = findShipyardDef(sim.getConfig()); REQUIRE(yardDef != nullptr); const BuildingId yardId = placeShipyard(sim, *yardDef); SimulationTestAccess::buildings(sim).setRecipe(yardId,"interceptor"); // Fill materials and tick to start production. ShipLayoutConfig emptyLayout; fillMaterials(sim, yardId, *def, emptyLayout); sim.tick(); const Building* b1 = sim.getBuildings().findBuilding(yardId); REQUIRE(b1 != nullptr); REQUIRE(b1->production.has_value()); // Now set a layout — should cancel production. ShipLayoutConfig layout; PlacedModule pm; pm.moduleId = "sensor_booster"; pm.position = QPoint(0, 0); pm.rotation = Rotation::East; layout.placedModules.push_back(pm); SimulationTestAccess::buildings(sim).setShipLayout(yardId, layout); const Building* b2 = sim.getBuildings().findBuilding(yardId); REQUIRE(b2 != nullptr); CHECK_FALSE(b2->production.has_value()); } TEST_CASE("Shipyard: builds a bare hull when no layout is configured", "[modules][shipyard]") { Simulation sim(loadTestConfig(), 42); const ShipDef* def = findSchematic(sim.getConfig(), "interceptor"); REQUIRE(def != nullptr); // The schematic carries a weapon in its (wave-only) default loadout. This // test pins that a player shipyard with no configured layout does NOT hand // that weapon out for free: it builds an unarmed bare hull, matching the // base-hull materials it was charged. REQUIRE_FALSE(def->defaultModules.empty()); const BuildingDef* yardDef = findShipyardDef(sim.getConfig()); REQUIRE(yardDef != nullptr); const BuildingId yardId = placeShipyard(sim, *yardDef); SimulationTestAccess::buildings(sim).setRecipe(yardId, "interceptor"); // Deliberately no setShipLayout: recipe set, layout left unconfigured. // Charge only the base-hull materials (an empty layout adds none). fillMaterials(sim, yardId, *def, ShipLayoutConfig{}); // Tick through one full production cycle so the ship spawns. const Tick cycleTicks = secondsToTicks(def->schematic.productionTimeSeconds); for (Tick i = 0; i <= cycleTicks; ++i) { sim.tick(); } // Locate the freshly built player ship. entt::entity built = entt::null; sim.getAdmin().forEach( [&](entt::entity e, const ShipIdentityComponent& si, const FactionComponent& fac) { if (!fac.isEnemy && si.schematicId == "interceptor") { built = e; } }); REQUIRE(sim.getAdmin().isValid(built)); // Bare hull: the schematic's default weapon must NOT have been installed. const bool hasWeapon = findFirstWeaponChild(sim.getAdmin(), built) != entt::null; CHECK_FALSE(hasWeapon); } TEST_CASE("Shipyard: setRecipe clears ship layout", "[modules][shipyard]") { Simulation sim(loadTestConfig(), 42); const BuildingDef* yardDef = findShipyardDef(sim.getConfig()); REQUIRE(yardDef != nullptr); const BuildingId yardId = placeShipyard(sim, *yardDef); SimulationTestAccess::buildings(sim).setRecipe(yardId,"interceptor"); ShipLayoutConfig layout; PlacedModule pm; pm.moduleId = "armor_plate"; pm.position = QPoint(0, 0); pm.rotation = Rotation::East; layout.placedModules.push_back(pm); SimulationTestAccess::buildings(sim).setShipLayout(yardId, layout); const Building* b1 = sim.getBuildings().findBuilding(yardId); REQUIRE(b1 != nullptr); REQUIRE(b1->shipLayout.has_value()); SimulationTestAccess::buildings(sim).setRecipe(yardId,"destroyer"); const Building* b2 = sim.getBuildings().findBuilding(yardId); REQUIRE(b2 != nullptr); CHECK_FALSE(b2->shipLayout.has_value()); } TEST_CASE("Shipyard: setRecipe with unchanged recipe keeps ship layout", "[modules][shipyard]") { Simulation sim(loadTestConfig(), 42); const BuildingDef* yardDef = findShipyardDef(sim.getConfig()); REQUIRE(yardDef != nullptr); const BuildingId yardId = placeShipyard(sim, *yardDef); SimulationTestAccess::buildings(sim).setRecipe(yardId,"interceptor"); ShipLayoutConfig layout; PlacedModule pm; pm.moduleId = "armor_plate"; pm.position = QPoint(0, 0); pm.rotation = Rotation::East; layout.placedModules.push_back(pm); SimulationTestAccess::buildings(sim).setShipLayout(yardId, layout); const Building* b1 = sim.getBuildings().findBuilding(yardId); REQUIRE(b1 != nullptr); REQUIRE(b1->shipLayout.has_value()); // Re-selecting the same recipe must be a no-op and preserve the layout. SimulationTestAccess::buildings(sim).setRecipe(yardId,"interceptor"); const Building* b2 = sim.getBuildings().findBuilding(yardId); REQUIRE(b2 != nullptr); REQUIRE(b2->shipLayout.has_value()); REQUIRE(b2->shipLayout->placedModules.size() == 1); CHECK(b2->shipLayout->placedModules[0].moduleId == "armor_plate"); } // --------------------------------------------------------------------------- // Weapon modifier simulation tests // --------------------------------------------------------------------------- TEST_CASE("Ship spawn: weapon_primer multiplies attack rate in simulation", "[modules]") { Simulation sim(loadTestConfig(), 42); const ShipDef* def = findSchematic(sim.getConfig(), "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.getShips().spawn("interceptor", QVector2D(5.0f, 5.0f), false, layout); const entt::entity weapon = findFirstWeaponChild(sim.getAdmin(), ship); REQUIRE(sim.getAdmin().isValid(weapon)); // base rate = 2.0 hz; weapon_primer multiplier = 1.2 → 2.4 hz CHECK(sim.getAdmin().get(weapon).fireRateHz == Approx(2.4f)); } TEST_CASE("Ship spawn: weapon_stabilizer multiplies attack range in simulation", "[modules]") { Simulation sim(loadTestConfig(), 42); const ShipDef* def = findSchematic(sim.getConfig(), "interceptor"); REQUIRE(def != nullptr); const float tileSize = static_cast(sim.getConfig().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.getShips().spawn("interceptor", QVector2D(5.0f, 5.0f), false, layout); const entt::entity weapon = findFirstWeaponChild(sim.getAdmin(), ship); REQUIRE(sim.getAdmin().isValid(weapon)); // base range = 50 m / tileSize = 5 tiles; weapon_stabilizer multiplier = 1.5 → 7.5 tiles CHECK(sim.getAdmin().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(loadTestConfig(), 42); const ShipDef* def = findSchematic(sim.getConfig(), "interceptor"); REQUIRE(def != nullptr); const float tileSize = static_cast(sim.getConfig().world.tileSize_m); const float tickRate = static_cast(kTickRateHz); const float base_mpss = static_cast(def->movement.mainAcceleration_mpss); 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.getShips().spawn("interceptor", 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.getAdmin().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(loadTestConfig(), 42); const ShipDef* def = findSchematic(sim.getConfig(), "interceptor"); REQUIRE(def != nullptr); const float tileSize = static_cast(sim.getConfig().world.tileSize_m); const float tickRate = static_cast(kTickRateHz); const float base_mpss = static_cast(def->movement.maneuveringAcceleration_mpss); 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.getShips().spawn("interceptor", 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.getAdmin().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 = loadTestConfig(); 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", 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 = loadTestConfig(); 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", 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 = loadTestConfig(); const ShipDef* def = findSchematic(cfg, "interceptor"); REQUIRE(def != nullptr); const float tileSize = static_cast(cfg.world.tileSize_m); const float base_mpss = static_cast(def->movement.mainAcceleration_mpss); 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", 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 = loadTestConfig(); const ShipDef* def = findSchematic(cfg, "interceptor"); REQUIRE(def != nullptr); const float tileSize = static_cast(cfg.world.tileSize_m); const float base_mpss = static_cast(def->movement.maneuveringAcceleration_mpss); 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", 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)); }