#include "catch.hpp" #include #include #include #include #include "AdvanceBehavior.h" #include "AttackBehavior.h" #include "BuildingId.h" #include "ConfigLoader.h" #include "DeliverScrapBehavior.h" #include "DynamicBodyComponent.h" #include "EntityAdmin.h" #include "HealthComponent.h" #include "ModuleOwnerComponent.h" #include "RallyBehavior.h" #include "RepairBehavior.h" #include "RepairToolComponent.h" #include "RetreatBehavior.h" #include "Rotation.h" #include "SalvageCargoComponent.h" #include "SalvageScrapBehavior.h" #include "SelectedBehaviorComponent.h" #include "SensorRangeComponent.h" #include "ShipLayout.h" #include "ShipSystem.h" #include "Tick.h" #include "WeaponComponent.h" static GameConfig loadConfig() { return ConfigLoader::loadFromDirectory(CONFIG_DIR); } // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- static entt::entity firstWeaponChild(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 entt::entity firstSalvageChild(EntityAdmin& admin, entt::entity ship) { entt::entity result = entt::null; admin.forEach( [&](entt::entity ce, const SalvageCargoComponent&, const ModuleOwnerComponent& o) { if (o.owner == ship && result == entt::null) { result = ce; } }); return result; } static entt::entity firstRepairChild(EntityAdmin& admin, entt::entity ship) { entt::entity result = entt::null; admin.forEach( [&](entt::entity ce, const RepairToolComponent&, const ModuleOwnerComponent& o) { if (o.owner == ship && result == entt::null) { result = ce; } }); return result; } static ShipLayoutConfig makeSingleModuleLayout(const std::string& moduleId) { PlacedModule pm; pm.moduleId = moduleId; pm.position = QPoint(0, 0); pm.rotation = Rotation::East; ShipLayoutConfig layout; layout.placedModules.push_back(pm); return layout; } // --------------------------------------------------------------------------- // Combat ship (interceptor has default_modules = [laser_cannon]) // --------------------------------------------------------------------------- TEST_CASE("ShipSystem: interceptor spawn has weapon child and attack behavior, no cargo or repair", "[ship]") { EntityAdmin admin; const GameConfig cfg = loadConfig(); ShipSystem ss(cfg, admin); const entt::entity e = ss.spawn("interceptor", 1, QVector2D(0.0f, 0.0f)); REQUIRE(admin.isValid(e)); REQUIRE(admin.isValid(firstWeaponChild(admin, e))); REQUIRE(admin.hasAll(e)); // Every ship gets the baseline behaviors; a player combat ship also rallies // and can retreat. REQUIRE(admin.hasAll(e)); REQUIRE(admin.hasAll(e)); REQUIRE(admin.hasAll(e)); REQUIRE(admin.hasAll(e)); REQUIRE_FALSE(admin.isValid(firstSalvageChild(admin, e))); REQUIRE_FALSE(admin.isValid(firstRepairChild(admin, e))); REQUIRE_FALSE(admin.hasAll(e)); REQUIRE_FALSE(admin.hasAll(e)); REQUIRE_FALSE(admin.hasAll(e)); } TEST_CASE("ShipSystem: enemy combat ship has no rally or retreat behavior", "[ship]") { EntityAdmin admin; const GameConfig cfg = loadConfig(); ShipSystem ss(cfg, admin); const entt::entity e = ss.spawn("interceptor", 1, QVector2D(0.0f, 0.0f), /*isEnemy=*/true); REQUIRE(admin.hasAll(e)); REQUIRE(admin.hasAll(e)); REQUIRE_FALSE(admin.hasAll(e)); REQUIRE_FALSE(admin.hasAll(e)); } TEST_CASE("ShipSystem: setRetreatEnabled(false) suppresses player retreat behavior", "[ship]") { EntityAdmin admin; const GameConfig cfg = loadConfig(); ShipSystem ss(cfg, admin); ss.setRetreatEnabled(false); const entt::entity e = ss.spawn("interceptor", 1, QVector2D(0.0f, 0.0f)); // Other player behaviors are unaffected; only retreat is suppressed. REQUIRE(admin.hasAll(e)); REQUIRE(admin.hasAll(e)); REQUIRE_FALSE(admin.hasAll(e)); } TEST_CASE("ShipSystem: interceptor level 1 stats match config formulas", "[ship]") { EntityAdmin admin; const GameConfig cfg = loadConfig(); ShipSystem ss(cfg, admin); const entt::entity e = ss.spawn("interceptor", 1, QVector2D(0.0f, 0.0f)); // hp_formula = "40 + 5*x" at x=1 → 45 REQUIRE(admin.get(e).maxHp == Approx(45.0f)); REQUIRE(admin.get(e).hp == Approx(45.0f)); // sensor_range_m_formula = "2000" m → 2000/10 = 200 tiles REQUIRE(admin.get(e).value_tiles == Approx(200.0f)); // laser_cannon: damage_formula = "2", attack_range_m_formula = "50" m → 50/10 = 5 tiles const entt::entity wc = firstWeaponChild(admin, e); REQUIRE(admin.isValid(wc)); REQUIRE(admin.get(wc).damage == Approx(2.0f)); REQUIRE(admin.get(wc).range_tiles == Approx(5.0f)); REQUIRE(admin.get(wc).cooldownTicks == Approx(0.0f)); } TEST_CASE("ShipSystem: interceptor level 5 hp matches formula", "[ship]") { EntityAdmin admin; const GameConfig cfg = loadConfig(); ShipSystem ss(cfg, admin); const entt::entity e = ss.spawn("interceptor", 5, QVector2D(0.0f, 0.0f)); // hp_formula = "40 + 5*x" at x=5 → 65 REQUIRE(admin.get(e).maxHp == Approx(65.0f)); } TEST_CASE("ShipSystem: interceptor level 0 maxSpeed_tpt matches formula / tileSize / kTickRateHz", "[ship]") { EntityAdmin admin; const GameConfig cfg = loadConfig(); ShipSystem ss(cfg, admin); const entt::entity e = ss.spawn("interceptor", 0, QVector2D(0.0f, 0.0f)); // speed_mps_formula = "2000 + 50*x" m/s at x=0 → 2000 m/s; maxSpeed_tpt = 2000/(10*30) const float expected = 2000.0f / 10.0f / static_cast(kTickRateHz); REQUIRE(admin.get(e).maxSpeed_tpt == Approx(expected)); } // --------------------------------------------------------------------------- // Salvage ship (spawned with salvager layout) // --------------------------------------------------------------------------- TEST_CASE("ShipSystem: salvage_ship spawn with salvage module has cargo child and behavior, no weapon", "[ship]") { EntityAdmin admin; const GameConfig cfg = loadConfig(); ShipSystem ss(cfg, admin); const ShipLayoutConfig layout = makeSingleModuleLayout("salvager"); const entt::entity e = ss.spawn("salvage_ship", 1, QVector2D(0.0f, 0.0f), false, layout); REQUIRE(admin.isValid(firstSalvageChild(admin, e))); REQUIRE(admin.hasAll(e)); REQUIRE(admin.hasAll(e)); REQUIRE_FALSE(admin.isValid(firstWeaponChild(admin, e))); REQUIRE_FALSE(admin.isValid(firstRepairChild(admin, e))); } TEST_CASE("ShipSystem: salvage_ship cargo capacity matches config", "[ship]") { EntityAdmin admin; const GameConfig cfg = loadConfig(); ShipSystem ss(cfg, admin); const ShipLayoutConfig layout = makeSingleModuleLayout("salvager"); const entt::entity e = ss.spawn("salvage_ship", 1, QVector2D(0.0f, 0.0f), false, layout); // salvager: cargo_capacity_formula = "10", collection_range_m_formula = "500" m → 500/10 = 50 tiles const entt::entity sc = firstSalvageChild(admin, e); REQUIRE(admin.isValid(sc)); REQUIRE(admin.get(sc).capacity == 10); REQUIRE(admin.get(sc).current == 0); REQUIRE(admin.get(e).deliveryBay == kInvalidBuildingId); REQUIRE_FALSE(admin.get(e).scrapTarget.has_value()); REQUIRE(admin.get(e).maxCollectionRange_tiles == Approx(50.0f)); } // --------------------------------------------------------------------------- // Repair ship (spawned with repair_tool layout) // --------------------------------------------------------------------------- TEST_CASE("ShipSystem: repair_ship spawn with repair module has repair child and behavior, no weapon", "[ship]") { EntityAdmin admin; const GameConfig cfg = loadConfig(); ShipSystem ss(cfg, admin); const ShipLayoutConfig layout = makeSingleModuleLayout("repair_tool"); const entt::entity e = ss.spawn("repair_ship", 1, QVector2D(0.0f, 0.0f), false, layout); REQUIRE(admin.isValid(firstRepairChild(admin, e))); REQUIRE(admin.hasAll(e)); REQUIRE_FALSE(admin.isValid(firstWeaponChild(admin, e))); REQUIRE_FALSE(admin.isValid(firstSalvageChild(admin, e))); } TEST_CASE("ShipSystem: repair_ship level 1 repair stats match config formulas", "[ship]") { EntityAdmin admin; const GameConfig cfg = loadConfig(); ShipSystem ss(cfg, admin); const ShipLayoutConfig layout = makeSingleModuleLayout("repair_tool"); const entt::entity e = ss.spawn("repair_ship", 1, QVector2D(0.0f, 0.0f), false, layout); // repair_tool: repair_rate_hz_formula = "5 + x" at x=1 → 6 / kTickRateHz const float expectedRate = 6.0f / static_cast(kTickRateHz); const entt::entity rc = firstRepairChild(admin, e); REQUIRE(admin.isValid(rc)); REQUIRE(admin.get(rc).ratePerTick == Approx(expectedRate)); // repair_range_m_formula = "800" m → 800/10 = 80 tiles REQUIRE(admin.get(rc).range_tiles == Approx(80.0f)); REQUIRE(admin.get(e).maxRepairRange_tiles == Approx(80.0f)); } // --------------------------------------------------------------------------- // Entity ids and removal // --------------------------------------------------------------------------- TEST_CASE("ShipSystem: spawned ships are valid entities", "[ship]") { EntityAdmin admin; const GameConfig cfg = loadConfig(); ShipSystem ss(cfg, admin); const entt::entity e1 = ss.spawn("interceptor", 1, QVector2D(0.0f, 0.0f)); const ShipLayoutConfig salvageLayout = makeSingleModuleLayout("salvager"); const entt::entity e2 = ss.spawn("salvage_ship", 1, QVector2D(1.0f, 0.0f), false, salvageLayout); REQUIRE(admin.isValid(e1)); REQUIRE(admin.isValid(e2)); REQUIRE(e1 != e2); } TEST_CASE("ShipSystem: despawn removes the ship and its weapon children", "[ship]") { EntityAdmin admin; const GameConfig cfg = loadConfig(); ShipSystem ss(cfg, admin); const entt::entity e = ss.spawn("interceptor", 1, QVector2D(0.0f, 0.0f)); const entt::entity wc = firstWeaponChild(admin, e); REQUIRE(admin.isValid(e)); REQUIRE(admin.isValid(wc)); ss.despawn(e); REQUIRE_FALSE(admin.isValid(e)); REQUIRE_FALSE(admin.isValid(wc)); }