#include "catch.hpp" #include #include #include "AiSystem.h" #include "BeltSystem.h" #include "Building.h" #include "BuildingSystem.h" #include "BuildingType.h" #include "ConfigLoader.h" #include "MovementSystem.h" #include "Rotation.h" #include "Scrap.h" #include "ScrapSystem.h" #include "Ship.h" #include "ShipSystem.h" #include "Tick.h" // --------------------------------------------------------------------------- // Fixture // --------------------------------------------------------------------------- static GameConfig loadConfig() { return ConfigLoader::loadFromDirectory(CONFIG_DIR); } struct Fixture { GameConfig cfg; BeltSystem belts; EntityId nextId; int stock; std::mt19937 rng; BuildingSystem buildings; ShipSystem ships; AiSystem ai; MovementSystem movement; ScrapSystem scraps; Tick tick; explicit Fixture() : cfg(loadConfig()) , belts(cfg.world.beltSpeedTilesPerSecond) , nextId(1) , stock(0) , rng(42) , buildings(cfg, belts, [this]() { return nextId++; }, [this](int n) { stock += n; }, [](const std::string&, QVector2D, const std::optional&) {}, rng) , ships(cfg, [this]() { return nextId++; }) , scraps([this]() { return nextId++; }) , tick(0) { } // Run one full behavior+movement tick (steps 7 and 10). void runBehaviorTick() { ships.clearMovementIntents(); ai.tickHomeReturn(ships); ai.tickThreatResponse(ships, buildings); ai.tickRepairBehavior(ships, buildings); ai.tickScrapCollector(ships, scraps, buildings); movement.tick(ships); ++tick; } }; // --------------------------------------------------------------------------- // clearMovementIntents // --------------------------------------------------------------------------- TEST_CASE("BehaviorSystem: clearMovementIntents resets all ships to priority 0", "[behavior]") { Fixture f; const EntityId id = f.ships.spawn("interceptor", 1, QVector2D(0.0f, 0.0f)); // Manually write a non-zero intent. f.ships.forEach([](Ship& s) { s.intent = MovementIntent{3, QVector2D(10.0f, 0.0f)}; }); f.ships.clearMovementIntents(); const Ship* s = f.ships.findShip(id); REQUIRE(s != nullptr); REQUIRE(s->intent.priority == 0); } // --------------------------------------------------------------------------- // tickMovement // --------------------------------------------------------------------------- // With facing=0 and target due east, main thrust drives the ship east. The test // config uses very high thrust so the ship reaches maxSpeedPerTick in one tick. TEST_CASE("BehaviorSystem: tickMovement advances ship by maxSpeedPerTick toward target", "[behavior]") { Fixture f; const EntityId id = f.ships.spawn("interceptor", 1, QVector2D(0.0f, 0.0f)); const float speed = f.ships.findShip(id)->maxSpeedPerTick; const QVector2D target(100.0f, 0.0f); f.ships.forEach([&target](Ship& s) { s.intent = MovementIntent{1, target}; }); f.movement.tick(f.ships); const Ship* s = f.ships.findShip(id); REQUIRE(s->position.x() == Approx(speed)); REQUIRE(s->position.y() == Approx(0.0f)); } // With very high maneuvering thrust the stopping distance is ~0, so desiredSpeed // still exceeds maxSpeedPerTick and the snap-to-target branch fires. TEST_CASE("BehaviorSystem: tickMovement stops exactly at target without overshoot", "[behavior]") { Fixture f; const EntityId id = f.ships.spawn("interceptor", 1, QVector2D(0.0f, 0.0f)); // Place target closer than one tick's travel. const float speed = f.ships.findShip(id)->maxSpeedPerTick; const QVector2D target(speed * 0.5f, 0.0f); f.ships.forEach([&target](Ship& s) { s.intent = MovementIntent{1, target}; }); f.movement.tick(f.ships); const Ship* s = f.ships.findShip(id); REQUIRE(s->position.x() == Approx(target.x())); REQUIRE(s->position.y() == Approx(target.y())); } // --------------------------------------------------------------------------- // tickHomeReturn // --------------------------------------------------------------------------- TEST_CASE("BehaviorSystem: tickHomeReturn does nothing when HP is above threshold", "[behavior]") { Fixture f; const EntityId id = f.ships.spawn("interceptor", 1, QVector2D(0.0f, 0.0f)); f.ships.forEach([](Ship& s) { s.homeReturn = HomeReturn{0.3f, QVector2D(-10.0f, 0.0f)}; s.hp = s.maxHp; // full HP — above threshold }); f.ships.clearMovementIntents(); f.ai.tickHomeReturn(f.ships); REQUIRE(f.ships.findShip(id)->intent.priority == 0); } TEST_CASE("BehaviorSystem: tickHomeReturn writes priority-4 intent toward homePos when HP is low", "[behavior]") { Fixture f; const EntityId id = f.ships.spawn("interceptor", 1, QVector2D(0.0f, 0.0f)); const QVector2D homePos(-10.0f, 0.0f); f.ships.forEach([&homePos](Ship& s) { s.homeReturn = HomeReturn{0.5f, homePos}; s.hp = s.maxHp * 0.2f; // below 50% threshold }); f.ships.clearMovementIntents(); f.ai.tickHomeReturn(f.ships); const Ship* s = f.ships.findShip(id); REQUIRE(s->intent.priority == 4); REQUIRE(s->intent.target.x() == Approx(homePos.x())); } TEST_CASE("BehaviorSystem: tickHomeReturn priority-4 beats tickThreatResponse priority-3", "[behavior]") { Fixture f; // Player ship with both homeReturn (low HP) and an enemy in range. const EntityId playerId = f.ships.spawn("interceptor", 1, QVector2D(0.0f, 0.0f)); f.ships.spawn("interceptor", 1, QVector2D(5.0f, 0.0f), /*isEnemy=*/true); const QVector2D homePos(-50.0f, 0.0f); f.ships.forEach([&homePos, playerId](Ship& s) { if (s.id == playerId) { s.homeReturn = HomeReturn{0.5f, homePos}; s.hp = s.maxHp * 0.1f; } }); f.ships.clearMovementIntents(); f.ai.tickHomeReturn(f.ships); f.ai.tickThreatResponse(f.ships, f.buildings); const Ship* s = f.ships.findShip(playerId); REQUIRE(s->intent.priority == 4); REQUIRE(s->intent.target.x() == Approx(homePos.x())); } // --------------------------------------------------------------------------- // tickThreatResponse — player ships // --------------------------------------------------------------------------- TEST_CASE("BehaviorSystem: player combat ship acquires nearest enemy ship in range", "[behavior]") { Fixture f; const EntityId playerId = f.ships.spawn("interceptor", 1, QVector2D(0.0f, 0.0f)); // Spawn enemy within attack range (150 tile units). const EntityId enemyId = f.ships.spawn("interceptor", 1, QVector2D(10.0f, 0.0f), /*isEnemy=*/true); f.ships.clearMovementIntents(); f.ai.tickThreatResponse(f.ships, f.buildings); const Ship* player = f.ships.findShip(playerId); REQUIRE(player->threatResponse.has_value()); REQUIRE(player->threatResponse->currentTarget.has_value()); REQUIRE(*player->threatResponse->currentTarget == enemyId); } TEST_CASE("BehaviorSystem: player combat ship does not target friendly ships", "[behavior]") { Fixture f; const EntityId id1 = f.ships.spawn("interceptor", 1, QVector2D(0.0f, 0.0f)); f.ships.spawn("interceptor", 1, QVector2D(5.0f, 0.0f)); // also player (isEnemy=false) f.ships.clearMovementIntents(); f.ai.tickThreatResponse(f.ships, f.buildings); const Ship* s = f.ships.findShip(id1); REQUIRE(s->threatResponse.has_value()); REQUIRE_FALSE(s->threatResponse->currentTarget.has_value()); } TEST_CASE("BehaviorSystem: player combat ship ignores enemy beyond engagement range", "[behavior]") { Fixture f; const EntityId playerId = f.ships.spawn("interceptor", 1, QVector2D(0.0f, 0.0f)); // Place enemy far beyond engagement range (150 tile units). f.ships.spawn("interceptor", 1, QVector2D(500.0f, 0.0f), /*isEnemy=*/true); f.ships.clearMovementIntents(); f.ai.tickThreatResponse(f.ships, f.buildings); const Ship* s = f.ships.findShip(playerId); REQUIRE_FALSE(s->threatResponse->currentTarget.has_value()); } // --------------------------------------------------------------------------- // tickThreatResponse — enemy ships // --------------------------------------------------------------------------- TEST_CASE("BehaviorSystem: enemy ship acquires nearest player ship in range", "[behavior]") { Fixture f; const EntityId playerId = f.ships.spawn("interceptor", 1, QVector2D(0.0f, 0.0f)); const EntityId enemyId = f.ships.spawn("interceptor", 1, QVector2D(10.0f, 0.0f), /*isEnemy=*/true); f.ships.clearMovementIntents(); f.ai.tickThreatResponse(f.ships, f.buildings); const Ship* enemy = f.ships.findShip(enemyId); REQUIRE(enemy->threatResponse.has_value()); REQUIRE(enemy->threatResponse->currentTarget.has_value()); REQUIRE(*enemy->threatResponse->currentTarget == playerId); } TEST_CASE("BehaviorSystem: enemy ship with no target writes leftward movement intent", "[behavior]") { Fixture f; const EntityId enemyId = f.ships.spawn("interceptor", 1, QVector2D(100.0f, 0.0f), /*isEnemy=*/true); f.ships.clearMovementIntents(); f.ai.tickThreatResponse(f.ships, f.buildings); const Ship* enemy = f.ships.findShip(enemyId); REQUIRE(enemy->intent.priority == 3); REQUIRE(enemy->intent.target.x() < 0.0f); // moving leftward (toward asteroid) } // --------------------------------------------------------------------------- // tickRepairBehavior // --------------------------------------------------------------------------- TEST_CASE("BehaviorSystem: repair ship writes intent toward damaged friendly ship", "[behavior]") { Fixture f; const EntityId repairId = f.ships.spawn("repair_ship", 1, QVector2D(0.0f, 0.0f)); const EntityId friendlyId = f.ships.spawn("interceptor", 1, QVector2D(5.0f, 0.0f)); // Damage the friendly ship. f.ships.forEach([friendlyId](Ship& s) { if (s.id == friendlyId) { s.hp = s.maxHp * 0.5f; } }); f.ships.clearMovementIntents(); f.ai.tickRepairBehavior(f.ships, f.buildings); const Ship* repair = f.ships.findShip(repairId); REQUIRE(repair->intent.priority == 2); REQUIRE(repair->intent.target.x() == Approx(5.0f)); } TEST_CASE("BehaviorSystem: repair ship heals damaged ally within repair range", "[behavior]") { Fixture f; // Repair range = 80 tile units; place ships close together. const EntityId repairId = f.ships.spawn("repair_ship", 1, QVector2D(0.0f, 0.0f)); const EntityId friendlyId = f.ships.spawn("interceptor", 1, QVector2D(1.0f, 0.0f)); const float initialHp = f.ships.findShip(friendlyId)->maxHp * 0.5f; f.ships.forEach([friendlyId, initialHp](Ship& s) { if (s.id == friendlyId) { s.hp = initialHp; } }); f.ships.clearMovementIntents(); f.ai.tickRepairBehavior(f.ships, f.buildings); // repair_rate_formula = "5 + x" at x=1 → 6; hp should have increased. const Ship* friendly = f.ships.findShip(friendlyId); REQUIRE(friendly->hp > initialHp); } TEST_CASE("BehaviorSystem: repair ship does not heal above maxHp", "[behavior]") { Fixture f; const EntityId repairId = f.ships.spawn("repair_ship", 1, QVector2D(0.0f, 0.0f)); const EntityId friendlyId = f.ships.spawn("interceptor", 1, QVector2D(1.0f, 0.0f)); // Nearly full HP — one repair tick must not exceed maxHp. f.ships.forEach([friendlyId](Ship& s) { if (s.id == friendlyId) { s.hp = s.maxHp - 0.001f; } }); for (int i = 0; i < 5; ++i) { f.ships.clearMovementIntents(); f.ai.tickRepairBehavior(f.ships, f.buildings); } const Ship* friendly = f.ships.findShip(friendlyId); REQUIRE(friendly->hp <= friendly->maxHp); REQUIRE(friendly->hp == Approx(friendly->maxHp)); } // --------------------------------------------------------------------------- // tickScrapCollector // --------------------------------------------------------------------------- TEST_CASE("BehaviorSystem: salvage ship writes intent toward nearest scrap", "[behavior]") { Fixture f; const EntityId shipId = f.ships.spawn("salvage_ship", 1, QVector2D(0.0f, 0.0f)); // Scrap beyond collectionRange (50) but within sensorRange (250). const QVector2D scrapPos(100.0f, 0.0f); const Tick farFuture = 100000; f.scraps.spawn(scrapPos, 1, farFuture); f.ships.clearMovementIntents(); f.ai.tickScrapCollector(f.ships, f.scraps, f.buildings); const Ship* s = f.ships.findShip(shipId); REQUIRE(s->intent.priority == 1); REQUIRE(s->intent.target.x() == Approx(scrapPos.x())); } TEST_CASE("BehaviorSystem: salvage ship collects scrap on arrival", "[behavior]") { Fixture f; // Place scrap exactly at ship position so it is within collectionRange immediately. const EntityId shipId = f.ships.spawn("salvage_ship", 1, QVector2D(0.0f, 0.0f)); const Tick farFuture = 100000; const EntityId scrapId = f.scraps.spawn(QVector2D(0.0f, 0.0f), 1, farFuture); f.ships.clearMovementIntents(); f.ai.tickScrapCollector(f.ships, f.scraps, f.buildings); const Ship* s = f.ships.findShip(shipId); REQUIRE(s->cargo->current == 1); REQUIRE(f.scraps.findScrap(scrapId) == nullptr); // consumed } TEST_CASE("BehaviorSystem: full-cargo salvage ship moves toward SalvageBay", "[behavior]") { Fixture f; // Place a SalvageBay building so the ship has somewhere to deliver. // The SalvageBay occupies asteroid tiles (x < 0 convention); use negative coords. // We bypass construction time by ticking until it is operational. const EntityId bayId = f.buildings.place(BuildingType::SalvageBay, QPoint(-4, 0), Rotation::East, 0); Tick tick = 0; // SalvageBay construction_time_seconds = 15 → 450 ticks; run 500 to be safe. for (int i = 0; i < 500; ++i) { f.buildings.tickConstruction(tick++); if (f.buildings.findBuilding(bayId) != nullptr) { break; } } REQUIRE(f.buildings.findBuilding(bayId) != nullptr); // Spawn salvage ship and fill its cargo. const EntityId shipId = f.ships.spawn("salvage_ship", 1, QVector2D(5.0f, 0.0f)); f.ships.forEach([](Ship& s) { if (s.cargo) { s.cargo->current = s.cargo->capacity; // full cargo } }); f.ships.clearMovementIntents(); f.ai.tickScrapCollector(f.ships, f.scraps, f.buildings); // Intent should point toward the bay (x < 0 area), not rightward. const Ship* s = f.ships.findShip(shipId); REQUIRE(s->intent.priority == 1); REQUIRE(s->intent.target.x() < s->position.x()); } // --------------------------------------------------------------------------- // Sensor range — spawn // --------------------------------------------------------------------------- TEST_CASE("SensorRange: sensorRange is populated from config formula at spawn", "[sensor]") { Fixture f; // interceptor sensor_range_formula = "200" (test config); verify at level 1. const EntityId id = f.ships.spawn("interceptor", 1, QVector2D(0.0f, 0.0f)); REQUIRE(f.ships.findShip(id)->sensorRange == Approx(200.0f)); } // --------------------------------------------------------------------------- // Sensor range — tickThreatResponse // --------------------------------------------------------------------------- TEST_CASE("SensorRange: player combat ship acquires enemy just inside sensor range", "[sensor]") { Fixture f; // interceptor sensor_range = 200 (test config); enemy at 190 tiles. const EntityId playerId = f.ships.spawn("interceptor", 1, QVector2D(0.0f, 0.0f)); const EntityId enemyId = f.ships.spawn("interceptor", 1, QVector2D(190.0f, 0.0f), /*isEnemy=*/true); f.ships.clearMovementIntents(); f.ai.tickThreatResponse(f.ships, f.buildings); const Ship* player = f.ships.findShip(playerId); REQUIRE(player->threatResponse->currentTarget == enemyId); } TEST_CASE("SensorRange: player combat ship ignores enemy just outside sensor range", "[sensor]") { Fixture f; // interceptor sensor_range = 200 (test config); enemy at 210 tiles. const EntityId playerId = f.ships.spawn("interceptor", 1, QVector2D(0.0f, 0.0f)); f.ships.spawn("interceptor", 1, QVector2D(210.0f, 0.0f), /*isEnemy=*/true); f.ships.clearMovementIntents(); f.ai.tickThreatResponse(f.ships, f.buildings); const Ship* player = f.ships.findShip(playerId); REQUIRE_FALSE(player->threatResponse->currentTarget.has_value()); } TEST_CASE("SensorRange: enemy ship ignores player just outside sensor range", "[sensor]") { Fixture f; // interceptor sensor_range = 200 (test config); player at 210 tiles from enemy. f.ships.spawn("interceptor", 1, QVector2D(0.0f, 0.0f)); const EntityId enemyId = f.ships.spawn("interceptor", 1, QVector2D(210.0f, 0.0f), /*isEnemy=*/true); f.ships.clearMovementIntents(); f.ai.tickThreatResponse(f.ships, f.buildings); const Ship* enemy = f.ships.findShip(enemyId); REQUIRE_FALSE(enemy->threatResponse->currentTarget.has_value()); } // --------------------------------------------------------------------------- // Sensor range — tickRepairBehavior // --------------------------------------------------------------------------- TEST_CASE("SensorRange: repair ship retreats from enemy within sensor range", "[sensor]") { Fixture f; // repair_ship sensor_range = 250; enemy at 200 tiles. const EntityId repairId = f.ships.spawn("repair_ship", 1, QVector2D(0.0f, 0.0f)); f.ships.spawn("interceptor", 1, QVector2D(200.0f, 0.0f), /*isEnemy=*/true); f.ships.clearMovementIntents(); f.ai.tickRepairBehavior(f.ships, f.buildings); const Ship* repair = f.ships.findShip(repairId); REQUIRE(repair->intent.priority == 2); REQUIRE(repair->intent.target.x() < 0.0f); // retreating leftward } TEST_CASE("SensorRange: repair ship does not retreat from enemy beyond sensor range", "[sensor]") { Fixture f; // repair_ship sensor_range = 250; enemy at 300 tiles. const EntityId repairId = f.ships.spawn("repair_ship", 1, QVector2D(0.0f, 0.0f)); f.ships.spawn("interceptor", 1, QVector2D(300.0f, 0.0f), /*isEnemy=*/true); f.ships.clearMovementIntents(); f.ai.tickRepairBehavior(f.ships, f.buildings); // Enemy outside sensor range → repair ship patrols rightward instead of retreating. const Ship* repair = f.ships.findShip(repairId); REQUIRE(repair->intent.target.x() > repair->position.x()); } TEST_CASE("SensorRange: repair ship does not acquire damaged ally beyond sensor range", "[sensor]") { Fixture f; // repair_ship sensor_range = 250; damaged friendly at 300 tiles. const EntityId repairId = f.ships.spawn("repair_ship", 1, QVector2D(0.0f, 0.0f)); const EntityId friendlyId = f.ships.spawn("interceptor", 1, QVector2D(300.0f, 0.0f)); f.ships.forEach([friendlyId](Ship& s) { if (s.id == friendlyId) { s.hp = s.maxHp * 0.5f; } }); f.ships.clearMovementIntents(); f.ai.tickRepairBehavior(f.ships, f.buildings); REQUIRE_FALSE(f.ships.findShip(repairId)->repairBehavior->currentTarget.has_value()); } // --------------------------------------------------------------------------- // Sensor range — tickScrapCollector // --------------------------------------------------------------------------- TEST_CASE("SensorRange: salvage ship ignores scrap beyond sensor range", "[sensor]") { Fixture f; // salvage_ship sensor_range = 250; scrap at 300 tiles. const EntityId shipId = f.ships.spawn("salvage_ship", 1, QVector2D(0.0f, 0.0f)); f.scraps.spawn(QVector2D(300.0f, 0.0f), 1, 100000); f.ships.clearMovementIntents(); f.ai.tickScrapCollector(f.ships, f.scraps, f.buildings); const Ship* s = f.ships.findShip(shipId); REQUIRE(s->scrapCollector->scrapTarget == std::nullopt); REQUIRE(s->intent.target.x() > s->position.x()); // patrolling rightward }