#include "catch.hpp" #include "FactoryQueries.h" #include #include #include #include #include #include "AdvanceBehavior.h" #include "AiSystem.h" #include "AttackBehavior.h" #include "BehaviorKind.h" #include "BeltSystem.h" #include "Building.h" #include "BuildingSystem.h" #include "FactoryState.h" #include "BuildingType.h" #include "ConfigLoader.h" #include "DeliverScrapBehavior.h" #include "DynamicBodyComponent.h" #include "DynamicBodySystem.h" #include "EntityAdmin.h" #include "FactionComponent.h" #include "HealthComponent.h" #include "HqProxyComponent.h" #include "ModuleOwnerComponent.h" #include "MovementIntentComponent.h" #include "MovementIntentSystem.h" #include "OrbitMath.h" #include "PositionComponent.h" #include "RallyBehavior.h" #include "RepairBehavior.h" #include "RepairSystem.h" #include "RepairToolComponent.h" #include "RetreatBehavior.h" #include "CargoComponent.h" #include "Rotation.h" #include "SalvageScrapBehavior.h" #include "SalvagerComponent.h" #include "SalvagerSystem.h" #include "DebrisSystem.h" #include "SelectedBehaviorComponent.h" #include "SensorRangeComponent.h" #include "ShipIdentityComponent.h" #include "ShipLayout.h" #include "ShipSystem.h" #include "Tick.h" #include "TestConfig.h" // --------------------------------------------------------------------------- // Fixture // --------------------------------------------------------------------------- struct Fixture { GameConfig cfg; FactoryState state; BeltSystem belts; BuildingId nextBuildingId; int stock; std::mt19937 rng; EntityAdmin admin; BuildingSystem buildings; ShipSystem ships; AiSystem ai; SalvagerSystem salvager; RepairSystem repair; MovementIntentSystem movementIntent; DynamicBodySystem dynamicBody; DebrisSystem scraps; Tick tick; std::vector beamEvents; explicit Fixture() : cfg(loadTestConfig()) , belts(cfg.world.beltSpeed_tps) , nextBuildingId(1) , stock(0) , rng(42) , buildings(cfg, state, belts, [this]() { return nextBuildingId++; }, [this](int n) { stock += n; }, [](const std::string&, QVector2D, const std::optional&) {}, [](const std::string&) -> bool { return true; }, rng) , ships(cfg, admin) , ai(cfg) , salvager(admin) , repair(admin) , scraps(admin) , tick(0) { } // Phase 1-3: clear intents, evaluate behaviors, select winners, execute. void decide() { ships.clearMovementIntents(); ai.tick(admin, state, scraps); } // World mutation: collection/delivery and healing. void runModules() { beamEvents.clear(); salvager.tick(tick, scraps, state, beamEvents); repair.tick(tick, beamEvents); } // Run one full behavior+movement tick (steps 7 and 10). void runBehaviorTick() { decide(); runModules(); movementIntent.tick(admin); dynamicBody.tick(admin); ++tick; } // One repair-system tick at the current sim time (advances the tick counter). // Starts cycles and applies any due (mid-beam-delayed) heals. void repairTick() { beamEvents.clear(); repair.tick(tick, beamEvents); ++tick; } // Drive the repair system long enough for a started cycle's delayed heal to land. void runRepairHeal() { for (int i = 0; i <= kBeamImpactDelayTicks; ++i) { repairTick(); } } // One salvage-system tick at the current sim time (advances the tick counter). void salvageTick() { beamEvents.clear(); salvager.tick(tick, scraps, state, beamEvents); ++tick; } // Drive the salvage system long enough for a started cycle's delayed collection. void runSalvageCollect() { for (int i = 0; i <= kBeamImpactDelayTicks; ++i) { salvageTick(); } } }; static ShipLayoutConfig makeSingleModuleLayout(const std::string& moduleId) { PlacedModule pm; pm.moduleId = moduleId; pm.position = QPoint(1, 1); pm.rotation = Rotation::East; ShipLayoutConfig layout; layout.placedModules.push_back(pm); return layout; } static ShipLayoutConfig makeTwoModuleLayout(const std::string& moduleId) { ShipLayoutConfig layout; PlacedModule pm1; pm1.moduleId = moduleId; pm1.position = QPoint(0, 0); pm1.rotation = Rotation::East; layout.placedModules.push_back(pm1); PlacedModule pm2; pm2.moduleId = moduleId; pm2.position = QPoint(0, 1); pm2.rotation = Rotation::East; layout.placedModules.push_back(pm2); return layout; } static entt::entity firstSalvageChild(EntityAdmin& admin, entt::entity ship) { entt::entity result = entt::null; admin.forEach( [&](entt::entity ce, const SalvagerComponent&, 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 std::vector allRepairChildren(EntityAdmin& admin, entt::entity ship) { std::vector result; admin.forEach( [&](entt::entity ce, const RepairToolComponent&, const ModuleOwnerComponent& o) { if (o.owner == ship) { result.push_back(ce); } }); return result; } static const MovementIntentComponent& intent(EntityAdmin& a, entt::entity e) { return a.get(e); } static BehaviorKind winnerOf(EntityAdmin& a, entt::entity e) { return a.get(e).winner; } static const HealthComponent& health(EntityAdmin& a, entt::entity e) { return a.get(e); } static const PositionComponent& pos(EntityAdmin& a, entt::entity e) { return a.get(e); } // --------------------------------------------------------------------------- // clearMovementIntents // --------------------------------------------------------------------------- TEST_CASE("BehaviorSystem: clearMovementIntents resets all ships to inactive", "[behavior]") { Fixture f; const entt::entity e = f.ships.spawn("interceptor", QVector2D(0.0f, 0.0f)); f.admin.get(e) = MovementIntentComponent{true, QVector2D(10.0f, 0.0f)}; f.ships.clearMovementIntents(); REQUIRE_FALSE(intent(f.admin, e).active); } // --------------------------------------------------------------------------- // tickMovement // --------------------------------------------------------------------------- TEST_CASE("BehaviorSystem: tickMovement advances ship by maxSpeed_tpt toward target", "[behavior]") { Fixture f; const entt::entity e = f.ships.spawn("interceptor", QVector2D(0.0f, 0.0f)); const float speed = f.admin.get(e).maxSpeed_tpt; f.admin.get(e) = MovementIntentComponent{true, QVector2D(100.0f, 0.0f)}; f.movementIntent.tick(f.admin); f.dynamicBody.tick(f.admin); REQUIRE(pos(f.admin, e).value.x() == Approx(speed)); REQUIRE(pos(f.admin, e).value.y() == Approx(0.0f)); } TEST_CASE("BehaviorSystem: tickMovement stops exactly at target without overshoot", "[behavior]") { Fixture f; const entt::entity e = f.ships.spawn("interceptor", QVector2D(0.0f, 0.0f)); const float speed = f.admin.get(e).maxSpeed_tpt; const QVector2D target(speed * 0.5f, 0.0f); f.admin.get(e) = MovementIntentComponent{true, target}; f.movementIntent.tick(f.admin); f.dynamicBody.tick(f.admin); REQUIRE(pos(f.admin, e).value.x() == Approx(target.x())); REQUIRE(pos(f.admin, e).value.y() == Approx(target.y())); } // --------------------------------------------------------------------------- // RetreatBehavior // --------------------------------------------------------------------------- TEST_CASE("BehaviorSystem: healthy player ship does not retreat", "[behavior]") { Fixture f; const entt::entity e = f.ships.spawn("interceptor", QVector2D(0.0f, 0.0f)); f.admin.get(e).hp = f.admin.get(e).maxHp; // full HP f.decide(); REQUIRE(winnerOf(f.admin, e) != BehaviorKind::Retreat); } TEST_CASE("BehaviorSystem: low-HP player ship retreats toward the rally point", "[behavior]") { Fixture f; const QVector2D rallyPoint(-50.0f, 0.0f); f.ships.setRallyPoint(rallyPoint); const entt::entity e = f.ships.spawn("interceptor", QVector2D(0.0f, 0.0f)); f.admin.get(e).hp = f.admin.get(e).maxHp * 0.2f; // below threshold f.decide(); REQUIRE(winnerOf(f.admin, e) == BehaviorKind::Retreat); REQUIRE(intent(f.admin, e).active); REQUIRE(intent(f.admin, e).target.x() == Approx(rallyPoint.x())); } TEST_CASE("BehaviorSystem: low-HP retreat outranks attacking a nearby enemy", "[behavior]") { Fixture f; const QVector2D rallyPoint(-50.0f, 0.0f); f.ships.setRallyPoint(rallyPoint); const entt::entity player = f.ships.spawn("interceptor", QVector2D(0.0f, 0.0f)); f.ships.spawn("interceptor", QVector2D(5.0f, 0.0f), /*isEnemy=*/true); f.admin.get(player).hp = f.admin.get(player).maxHp * 0.1f; f.decide(); REQUIRE(winnerOf(f.admin, player) == BehaviorKind::Retreat); REQUIRE(intent(f.admin, player).target.x() == Approx(rallyPoint.x())); } TEST_CASE("BehaviorSystem: enemy ships never retreat even at low HP", "[behavior]") { Fixture f; const entt::entity enemy = f.ships.spawn("interceptor", QVector2D(0.0f, 0.0f), /*isEnemy=*/true); f.admin.get(enemy).hp = f.admin.get(enemy).maxHp * 0.05f; f.decide(); REQUIRE_FALSE(f.admin.hasAll(enemy)); REQUIRE(winnerOf(f.admin, enemy) != BehaviorKind::Retreat); } // --------------------------------------------------------------------------- // AttackBehavior — player ships // --------------------------------------------------------------------------- TEST_CASE("BehaviorSystem: player combat ship acquires nearest enemy ship in range", "[behavior]") { Fixture f; const entt::entity player = f.ships.spawn("interceptor", QVector2D(0.0f, 0.0f)); const entt::entity enemy = f.ships.spawn("interceptor", QVector2D(10.0f, 0.0f), /*isEnemy=*/true); f.decide(); REQUIRE(f.admin.hasAll(player)); const AttackBehavior& attack = f.admin.get(player); REQUIRE(attack.currentTarget.has_value()); REQUIRE(*attack.currentTarget == enemy); REQUIRE(winnerOf(f.admin, player) == BehaviorKind::Attack); } TEST_CASE("BehaviorSystem: player combat ship does not target friendly ships", "[behavior]") { Fixture f; const entt::entity e1 = f.ships.spawn("interceptor", QVector2D(0.0f, 0.0f)); f.ships.spawn("interceptor", QVector2D(5.0f, 0.0f)); // also player f.decide(); REQUIRE(f.admin.hasAll(e1)); REQUIRE_FALSE(f.admin.get(e1).currentTarget.has_value()); REQUIRE(winnerOf(f.admin, e1) != BehaviorKind::Attack); } TEST_CASE("BehaviorSystem: player combat ship ignores enemy beyond engagement range", "[behavior]") { Fixture f; const entt::entity player = f.ships.spawn("interceptor", QVector2D(0.0f, 0.0f)); f.ships.spawn("interceptor", QVector2D(500.0f, 0.0f), /*isEnemy=*/true); f.decide(); REQUIRE_FALSE(f.admin.get(player).currentTarget.has_value()); } // --------------------------------------------------------------------------- // AttackBehavior — overclaim penalty & hysteresis // --------------------------------------------------------------------------- // Absent claims, the nearer enemy wins; once another ship claims that enemy, the // overclaim penalty steers the deciding ship to the unclaimed, equidistant one. TEST_CASE("BehaviorSystem: overclaim penalty steers a ship off a claimed target", "[behavior]") { SECTION("no claim: the nearer enemy is chosen") { Fixture f; const entt::entity player = f.ships.spawn("interceptor", QVector2D(0.0f, 0.0f)); const entt::entity nearEnemy = f.ships.spawn("interceptor", QVector2D(8.0f, 0.0f), /*isEnemy=*/true); f.ships.spawn("interceptor", QVector2D(0.0f, 12.0f), /*isEnemy=*/true); f.decide(); REQUIRE(f.admin.get(player).currentTarget == nearEnemy); } SECTION("enemyA already claimed: penalty redirects to the unclaimed enemyB") { const float d = 10.0f; Fixture f; const entt::entity player = f.ships.spawn("interceptor", QVector2D(0.0f, 0.0f)); const entt::entity enemyA = f.ships.spawn("interceptor", QVector2D(d, 0.0f), /*isEnemy=*/true); const entt::entity enemyB = f.ships.spawn("interceptor", QVector2D(0.0f, d), /*isEnemy=*/true); // A second player ship already commits to enemyA, registering a claim, so // the penalised score of enemyA falls below the unclaimed equidistant enemyB. const entt::entity claimant = f.ships.spawn("interceptor", QVector2D(d, 1.0f)); f.admin.get(claimant).currentTarget = enemyA; f.decide(); REQUIRE(f.admin.get(player).currentTarget == enemyB); } } // Hysteresis keeps a ship on its committed target when a fresh candidate is only // marginally better — and self-exclusion means the ship's own claim never counts // against the target it already holds. TEST_CASE("BehaviorSystem: hysteresis keeps a ship on its own claimed target", "[behavior]") { const float d = 10.0f; Fixture f; const entt::entity player = f.ships.spawn("interceptor", QVector2D(0.0f, 0.0f)); const entt::entity enemyA = f.ships.spawn("interceptor", QVector2D(d, 0.0f), /*isEnemy=*/true); f.ships.spawn("interceptor", QVector2D(0.0f, d), /*isEnemy=*/true); // The ship already holds enemyA; enemyB is equidistant. Without self-exclusion // the ship's own claim would penalise enemyA and flip the choice. f.admin.get(player).currentTarget = enemyA; f.decide(); REQUIRE(f.admin.get(player).currentTarget == enemyA); } // When the held target becomes heavily overclaimed by others, its penalised score // drops far enough that an equidistant unclaimed enemy beats the hysteresis margin // and the ship switches. TEST_CASE("BehaviorSystem: a ship switches off a heavily overclaimed target", "[behavior]") { const float d = 10.0f; Fixture f; const entt::entity player = f.ships.spawn("interceptor", QVector2D(0.0f, 0.0f)); const entt::entity enemyA = f.ships.spawn("interceptor", QVector2D(d, 0.0f), /*isEnemy=*/true); const entt::entity enemyB = f.ships.spawn("interceptor", QVector2D(0.0f, d), /*isEnemy=*/true); f.admin.get(player).currentTarget = enemyA; // Five other ships also commit to enemyA, saturating the claim penalty (0.5). for (int i = 0; i < 5; ++i) { const entt::entity other = f.ships.spawn("interceptor", QVector2D(d, static_cast(2 + i))); f.admin.get(other).currentTarget = enemyA; } f.decide(); REQUIRE(f.admin.get(player).currentTarget == enemyB); } // --------------------------------------------------------------------------- // AttackBehavior — enemy ships // --------------------------------------------------------------------------- TEST_CASE("BehaviorSystem: enemy ship acquires nearest player ship in range", "[behavior]") { Fixture f; const entt::entity player = f.ships.spawn("interceptor", QVector2D(0.0f, 0.0f)); const entt::entity enemy = f.ships.spawn("interceptor", QVector2D(10.0f, 0.0f), /*isEnemy=*/true); f.decide(); REQUIRE(f.admin.hasAll(enemy)); const AttackBehavior& attack = f.admin.get(enemy); REQUIRE(attack.currentTarget.has_value()); REQUIRE(*attack.currentTarget == player); REQUIRE(winnerOf(f.admin, enemy) == BehaviorKind::Attack); } TEST_CASE("BehaviorSystem: enemy ship with no target advances leftward", "[behavior]") { Fixture f; const entt::entity enemy = f.ships.spawn("interceptor", QVector2D(100.0f, 0.0f), /*isEnemy=*/true); f.decide(); REQUIRE(winnerOf(f.admin, enemy) == BehaviorKind::Advance); REQUIRE(intent(f.admin, enemy).active); REQUIRE(intent(f.admin, enemy).target.x() < 0.0f); } TEST_CASE("BehaviorSystem: advancing ship targets center between enemy defence stations", "[behavior]") { Fixture f; // Two enemy defence stations far from the ship (out of sensor/attack range), // 1x1 footprint so each center is anchor + (0.5, 0.5). const std::vector body{QPoint(0, 0)}; f.admin.spawnStation(QPoint(1000, 10), QSize(1, 1), body, 100.0f, 100.0f, /*isEnemy=*/true); f.admin.spawnStation(QPoint(1000, 30), QSize(1, 1), body, 100.0f, 100.0f, /*isEnemy=*/true); const entt::entity player = f.ships.spawn("interceptor", QVector2D(0.0f, 0.0f), /*isEnemy=*/false); // Player ships rally until departure; drop Rally so Advance is the fallback. f.ships.triggerRallyDeparture(); f.decide(); // Centers (1000.5, 10.5) and (1000.5, 30.5) -> midpoint (1000.5, 20.5). REQUIRE(winnerOf(f.admin, player) == BehaviorKind::Advance); REQUIRE(intent(f.admin, player).active); REQUIRE(intent(f.admin, player).target.x() == Approx(1000.5f)); REQUIRE(intent(f.admin, player).target.y() == Approx(20.5f)); } TEST_CASE("BehaviorSystem: advancing ship falls back to enemy HQ, then off-world", "[behavior]") { Fixture f; // Player HQ proxy (isEnemy=false) but no player defence stations. const QVector2D hqPos(5.0f, 7.0f); const entt::entity hq = f.admin.spawnHqProxy(hqPos, 100.0f, 100.0f); const entt::entity enemy = f.ships.spawn("interceptor", QVector2D(1000.0f, 0.0f), /*isEnemy=*/true); f.decide(); REQUIRE(winnerOf(f.admin, enemy) == BehaviorKind::Advance); REQUIRE(intent(f.admin, enemy).active); REQUIRE(intent(f.admin, enemy).target.x() == Approx(hqPos.x())); REQUIRE(intent(f.admin, enemy).target.y() == Approx(hqPos.y())); // With the HQ gone too, the ship falls back to advancing off-world (leftward). f.admin.get(hq).hp = 0.0f; f.decide(); REQUIRE(winnerOf(f.admin, enemy) == BehaviorKind::Advance); REQUIRE(intent(f.admin, enemy).target.x() < 0.0f); } // --------------------------------------------------------------------------- // RepairBehavior // --------------------------------------------------------------------------- TEST_CASE("BehaviorSystem: repair ship orbits damaged friendly ship", "[behavior]") { Fixture f; const ShipLayoutConfig repairLayout = makeSingleModuleLayout("repair_tool"); const entt::entity repairShip = f.ships.spawn("repair_ship", QVector2D(0.0f, 0.0f), false, repairLayout); const entt::entity friendly = f.ships.spawn("interceptor", QVector2D(5.0f, 0.0f)); f.admin.get(friendly).hp = f.admin.get(friendly).maxHp * 0.5f; f.decide(); REQUIRE(winnerOf(f.admin, repairShip) == BehaviorKind::Repair); REQUIRE(intent(f.admin, repairShip).active); // Orbit at orbit_factor * max repair range (REQ-SHP-ORBIT): the intent carries // the target's center and the orbit radius; MovementIntentSystem turns these // into a point on the orbit circle when it steers. const float orbitRadius = f.admin.get(repairShip).orbitRadius_tiles; REQUIRE(orbitRadius > 0.0f); REQUIRE(intent(f.admin, repairShip).target == pos(f.admin, friendly).value); REQUIRE(intent(f.admin, repairShip).orbitRadius_tiles == Approx(orbitRadius)); } // --------------------------------------------------------------------------- // StandbyBehavior (repair ships hold with the fleet when idle) // --------------------------------------------------------------------------- TEST_CASE("BehaviorSystem: idle repair ship stands by with the fleet instead of charging the enemy", "[behavior]") { Fixture f; const ShipLayoutConfig repairLayout = makeSingleModuleLayout("repair_tool"); const entt::entity repairShip = f.ships.spawn("repair_ship", QVector2D(0.0f, 0.0f), false, repairLayout); // A healthy ally (nothing to repair) and a far enemy station (no threat in range). const entt::entity ally = f.ships.spawn("interceptor", QVector2D(50.0f, 0.0f)); const std::vector body{QPoint(0, 0)}; f.admin.spawnStation(QPoint(1000, 0), QSize(1, 1), body, 100.0f, 100.0f, /*isEnemy=*/true); f.decide(); // With no damaged ally and no enemy in sensor range the repair ship neither // repairs nor retreats: it stands by (REQ-SHP-STANDBY), steering toward its // fleet (the ally) rather than charging the distant enemy station. REQUIRE(winnerOf(f.admin, repairShip) == BehaviorKind::Standby); REQUIRE(intent(f.admin, repairShip).active); REQUIRE(intent(f.admin, repairShip).target.x() == Approx(pos(f.admin, ally).value.x())); REQUIRE(intent(f.admin, repairShip).target.y() == Approx(pos(f.admin, ally).value.y())); } TEST_CASE("BehaviorSystem: repair ship heals damaged ally within repair range", "[behavior]") { Fixture f; const ShipLayoutConfig repairLayout = makeSingleModuleLayout("repair_tool"); f.ships.spawn("repair_ship", QVector2D(0.0f, 0.0f), false, repairLayout); const entt::entity friendly = f.ships.spawn("interceptor", QVector2D(1.0f, 0.0f)); const float initialHp = f.admin.get(friendly).maxHp * 0.5f; f.admin.get(friendly).hp = initialHp; f.decide(); f.runRepairHeal(); REQUIRE(health(f.admin, friendly).hp > initialHp); } TEST_CASE("BehaviorSystem: repair ship does not heal above maxHp", "[behavior]") { Fixture f; const ShipLayoutConfig repairLayout = makeSingleModuleLayout("repair_tool"); f.ships.spawn("repair_ship", QVector2D(0.0f, 0.0f), false, repairLayout); const entt::entity friendly = f.ships.spawn("interceptor", QVector2D(1.0f, 0.0f)); f.admin.get(friendly).hp = f.admin.get(friendly).maxHp - 0.001f; f.decide(); f.runRepairHeal(); const HealthComponent& h = health(f.admin, friendly); REQUIRE(h.hp <= h.maxHp); REQUIRE(h.hp == Approx(h.maxHp)); } // --------------------------------------------------------------------------- // RepairSystem — per-module targeting // --------------------------------------------------------------------------- TEST_CASE("RepairSystem: tool heals the in-range damaged target chosen by the executor", "[behavior]") { Fixture f; const ShipLayoutConfig repairLayout = makeSingleModuleLayout("repair_tool"); const entt::entity repairShip = f.ships.spawn("repair_ship", QVector2D(0.0f, 0.0f), false, repairLayout); const entt::entity friendly = f.ships.spawn("interceptor", QVector2D(10.0f, 0.0f)); const float initHp = f.admin.get(friendly).maxHp * 0.5f; f.admin.get(friendly).hp = initHp; f.decide(); f.runRepairHeal(); const entt::entity rc = firstRepairChild(f.admin, repairShip); REQUIRE(f.admin.isValid(rc)); REQUIRE(f.admin.get(rc).currentTarget.has_value()); REQUIRE(*f.admin.get(rc).currentTarget == friendly); REQUIRE(health(f.admin, friendly).hp > initHp); } TEST_CASE("RepairSystem: tool falls back to in-range target when its target is out of repair range", "[behavior]") { Fixture f; const ShipLayoutConfig repairLayout = makeSingleModuleLayout("repair_tool"); const entt::entity repairShip = f.ships.spawn("repair_ship", QVector2D(0.0f, 0.0f), false, repairLayout); // out of repair range (80) but in sensor range (200) const entt::entity outOfRange = f.ships.spawn("interceptor", QVector2D(90.0f, 0.0f)); // within repair range const entt::entity fallback = f.ships.spawn("interceptor", QVector2D(20.0f, 0.0f)); const float outInitHp = f.admin.get(outOfRange).maxHp * 0.5f; const float fallbackInitHp = f.admin.get(fallback).maxHp * 0.5f; f.admin.get(outOfRange).hp = outInitHp; f.admin.get(fallback).hp = fallbackInitHp; // Seed the tool with an out-of-range target; RepairSystem must reacquire. const entt::entity rc = firstRepairChild(f.admin, repairShip); f.admin.get(rc).currentTarget = outOfRange; f.runRepairHeal(); REQUIRE(f.admin.get(rc).currentTarget.has_value()); REQUIRE(*f.admin.get(rc).currentTarget == fallback); REQUIRE(health(f.admin, fallback).hp > fallbackInitHp); REQUIRE(health(f.admin, outOfRange).hp == Approx(outInitHp)); } TEST_CASE("RepairSystem: tool falls back when its target is fully healed", "[behavior]") { Fixture f; const ShipLayoutConfig repairLayout = makeSingleModuleLayout("repair_tool"); const entt::entity repairShip = f.ships.spawn("repair_ship", QVector2D(0.0f, 0.0f), false, repairLayout); const entt::entity healed = f.ships.spawn("interceptor", QVector2D(10.0f, 0.0f)); const entt::entity fallback = f.ships.spawn("interceptor", QVector2D(15.0f, 0.0f)); f.admin.get(healed).hp = f.admin.get(healed).maxHp; const float fallbackInitHp = f.admin.get(fallback).maxHp * 0.5f; f.admin.get(fallback).hp = fallbackInitHp; const entt::entity rc = firstRepairChild(f.admin, repairShip); f.admin.get(rc).currentTarget = healed; f.runRepairHeal(); REQUIRE(*f.admin.get(rc).currentTarget == fallback); REQUIRE(health(f.admin, fallback).hp > fallbackInitHp); } TEST_CASE("RepairSystem: tool falls back when its target is destroyed", "[behavior]") { Fixture f; const ShipLayoutConfig repairLayout = makeSingleModuleLayout("repair_tool"); const entt::entity repairShip = f.ships.spawn("repair_ship", QVector2D(0.0f, 0.0f), false, repairLayout); const entt::entity gone = f.ships.spawn("interceptor", QVector2D(10.0f, 0.0f)); const entt::entity fallback = f.ships.spawn("interceptor", QVector2D(15.0f, 0.0f)); const float fallbackInitHp = f.admin.get(fallback).maxHp * 0.5f; f.admin.get(fallback).hp = fallbackInitHp; const entt::entity rc = firstRepairChild(f.admin, repairShip); f.admin.get(rc).currentTarget = gone; f.ships.despawn(gone); f.runRepairHeal(); REQUIRE(*f.admin.get(rc).currentTarget == fallback); REQUIRE(health(f.admin, fallback).hp > fallbackInitHp); } TEST_CASE("RepairSystem: tool target is cleared when no repairable target is in range", "[behavior]") { Fixture f; const ShipLayoutConfig repairLayout = makeSingleModuleLayout("repair_tool"); const entt::entity repairShip = f.ships.spawn("repair_ship", QVector2D(0.0f, 0.0f), false, repairLayout); // damaged but beyond repair range (80) const entt::entity outOfRange = f.ships.spawn("interceptor", QVector2D(150.0f, 0.0f)); const float initHp = f.admin.get(outOfRange).maxHp * 0.5f; f.admin.get(outOfRange).hp = initHp; const entt::entity rc = firstRepairChild(f.admin, repairShip); f.admin.get(rc).currentTarget = outOfRange; f.runRepairHeal(); REQUIRE_FALSE(f.admin.get(rc).currentTarget.has_value()); REQUIRE(health(f.admin, outOfRange).hp == Approx(initHp)); } TEST_CASE("RepairSystem: two repair modules both heal the chosen target additively", "[behavior]") { Fixture f; const ShipLayoutConfig repairLayout = makeTwoModuleLayout("repair_tool"); const entt::entity repairShip = f.ships.spawn("repair_ship", QVector2D(0.0f, 0.0f), false, repairLayout); const entt::entity targetA = f.ships.spawn("interceptor", QVector2D(10.0f, 0.0f)); const float initHp = f.admin.get(targetA).maxHp * 0.5f; f.admin.get(targetA).hp = initHp; f.decide(); f.runRepairHeal(); // Both modules run one cycle and heal targetA — total increase is 2 * repairAmountHp. // repair_amount_hp_formula = "5 + x" at x=1 → 6 HP per cycle. const float repairAmountHp = 5.0f + 1.0f; REQUIRE(health(f.admin, targetA).hp == Approx(initHp + 2.0f * repairAmountHp)); const std::vector children = allRepairChildren(f.admin, repairShip); REQUIRE(children.size() == 2); for (const entt::entity child : children) { REQUIRE(f.admin.get(child).currentTarget == targetA); } } TEST_CASE("RepairSystem: two modules both fall back and heal the same target", "[behavior]") { Fixture f; const ShipLayoutConfig repairLayout = makeTwoModuleLayout("repair_tool"); const entt::entity repairShip = f.ships.spawn("repair_ship", QVector2D(0.0f, 0.0f), false, repairLayout); const entt::entity healed = f.ships.spawn("interceptor", QVector2D(10.0f, 0.0f)); const entt::entity targetB = f.ships.spawn("interceptor", QVector2D(20.0f, 0.0f)); f.admin.get(healed).hp = f.admin.get(healed).maxHp; const float initHp = f.admin.get(targetB).maxHp * 0.5f; f.admin.get(targetB).hp = initHp; // Seed both tools with the (fully-healed) target; they must reacquire targetB. for (const entt::entity child : allRepairChildren(f.admin, repairShip)) { f.admin.get(child).currentTarget = healed; } f.runRepairHeal(); const float repairAmountHp = 5.0f + 1.0f; REQUIRE(health(f.admin, targetB).hp == Approx(initHp + 2.0f * repairAmountHp)); const std::vector children = allRepairChildren(f.admin, repairShip); REQUIRE(children.size() == 2); for (const entt::entity child : children) { REQUIRE(f.admin.get(child).currentTarget == targetB); } } TEST_CASE("RepairSystem: does not crash when a tool's owner is not a repair ship", "[behavior]") { Fixture f; // Bare child entity: RepairToolComponent + ModuleOwnerComponent, owner is a combat ship. const entt::entity ownerShip = f.ships.spawn("interceptor", QVector2D(0.0f, 0.0f)); const entt::entity moduleEntity = f.admin.createModuleEntity(); RepairToolComponent rt; rt.repairAmountHp = 1.0f; rt.repairIntervalTicks = kTickRateHz; rt.cooldownTicksRemaining = 0; rt.range_tiles = 10.0f; rt.currentTarget = std::nullopt; f.admin.addComponent(moduleEntity, rt); f.admin.addComponent(moduleEntity, ModuleOwnerComponent{ownerShip}); // Must not crash; no damaged friendly in range, so no target is set. f.runRepairHeal(); REQUIRE_FALSE(f.admin.get(moduleEntity).currentTarget.has_value()); } TEST_CASE("RepairSystem: repair tool does not repair an HQ", "[behavior]") { Fixture f; const ShipLayoutConfig repairLayout = makeSingleModuleLayout("repair_tool"); const entt::entity repairShip = f.ships.spawn("repair_ship", QVector2D(0.0f, 0.0f), false, repairLayout); // A damaged, same-faction HQ in repair range — spawned as a station and tagged // as an HQ (as the balancing arena does). The HQ is not a repair target. const std::vector cells = {QPoint(2, 0), QPoint(3, 0), QPoint(2, 1), QPoint(3, 1)}; const entt::entity hq = f.admin.spawnStation(QPoint(2, 0), QSize(2, 2), cells, 100.0f, 200.0f, false); f.admin.addComponent(hq); f.decide(); f.runRepairHeal(); REQUIRE(f.admin.get(hq).hp == Approx(100.0f)); const entt::entity rc = firstRepairChild(f.admin, repairShip); REQUIRE_FALSE(f.admin.get(rc).currentTarget.has_value()); } TEST_CASE("RepairSystem: repair tool still repairs a damaged defence station", "[behavior]") { Fixture f; const ShipLayoutConfig repairLayout = makeSingleModuleLayout("repair_tool"); const entt::entity repairShip = f.ships.spawn("repair_ship", QVector2D(0.0f, 0.0f), false, repairLayout); // A damaged, same-faction defence station (no HQ tag) in repair range. const std::vector cells = {QPoint(2, 0), QPoint(3, 0), QPoint(2, 1), QPoint(3, 1)}; const entt::entity station = f.admin.spawnStation(QPoint(2, 0), QSize(2, 2), cells, 100.0f, 200.0f, false); f.decide(); f.runRepairHeal(); REQUIRE(f.admin.get(station).hp > 100.0f); const entt::entity rc = firstRepairChild(f.admin, repairShip); REQUIRE(*f.admin.get(rc).currentTarget == station); } // --------------------------------------------------------------------------- // SalvageScrapBehavior / DeliverScrapBehavior // --------------------------------------------------------------------------- TEST_CASE("BehaviorSystem: salvage ship orbits nearest scrap", "[behavior]") { Fixture f; const ShipLayoutConfig salvageLayout = makeSingleModuleLayout("salvager"); const entt::entity ship = f.ships.spawn("salvage_ship", QVector2D(0.0f, 0.0f), false, salvageLayout); const QVector2D scrapPos(100.0f, 0.0f); f.scraps.spawn(scrapPos, 1, 100000); f.decide(); REQUIRE(winnerOf(f.admin, ship) == BehaviorKind::SalvageScrap); REQUIRE(intent(f.admin, ship).active); // Orbit at orbit_factor * max collection range (REQ-SHP-ORBIT): the intent // carries the scrap center and the orbit radius. const float orbitRadius = f.admin.get(ship).orbitRadius_tiles; REQUIRE(orbitRadius > 0.0f); REQUIRE(intent(f.admin, ship).target == scrapPos); REQUIRE(intent(f.admin, ship).orbitRadius_tiles == Approx(orbitRadius)); } TEST_CASE("BehaviorSystem: salvage ship collects scrap on arrival", "[behavior]") { Fixture f; const ShipLayoutConfig salvageLayout = makeSingleModuleLayout("salvager"); const entt::entity ship = f.ships.spawn("salvage_ship", QVector2D(0.0f, 0.0f), false, salvageLayout); const entt::entity scrapEntity = f.scraps.spawn(QVector2D(0.0f, 0.0f), 1, 100000); f.runSalvageCollect(); const entt::entity sc = firstSalvageChild(f.admin, ship); REQUIRE(f.admin.isValid(sc)); REQUIRE(f.admin.get(ship).current == 1); REQUIRE_FALSE(f.admin.isValid(scrapEntity)); } TEST_CASE("BehaviorSystem: full-cargo salvage ship moves toward SalvageBay", "[behavior]") { Fixture f; const BuildingId bayId = f.buildings.place(BuildingType::SalvageBay, QPoint(-4, 0), Rotation::East, 0).value(); Tick t = 0; for (int i = 0; i < 500; ++i) { f.buildings.tickConstruction(t++); if (findBuilding(f.state, bayId) != nullptr) { break; } } REQUIRE(findBuilding(f.state, bayId) != nullptr); const ShipLayoutConfig salvageLayout = makeSingleModuleLayout("salvager"); const entt::entity ship = f.ships.spawn("salvage_ship", QVector2D(5.0f, 0.0f), false, salvageLayout); { REQUIRE(f.admin.isValid(firstSalvageChild(f.admin, ship))); CargoComponent& cargo = f.admin.get(ship); cargo.current = cargo.maxCapacity; // full cargo } f.decide(); REQUIRE(winnerOf(f.admin, ship) == BehaviorKind::DeliverScrap); REQUIRE(f.admin.get(ship).deliveryBay == bayId); const MovementIntentComponent& i = intent(f.admin, ship); REQUIRE(i.active); REQUIRE(i.target.x() < pos(f.admin, ship).value.x()); } TEST_CASE("SalvagerSystem: full-cargo ship at its SalvageBay hands over cargo", "[behavior]") { Fixture f; const BuildingId bayId = f.buildings.place(BuildingType::SalvageBay, QPoint(-4, 0), Rotation::East, 0).value(); Tick t = 0; for (int i = 0; i < 500; ++i) { f.buildings.tickConstruction(t++); if (findBuilding(f.state, bayId) != nullptr) { break; } } const Building* bay = findBuilding(f.state, bayId); REQUIRE(bay != nullptr); // Config-driven output-buffer capacity is applied on placement (REQ-BLD-SALVAGE-BAY). REQUIRE(bay->outputBuffer.capacity == 20); const QVector2D bayCenter(bay->anchor.x() + bay->footprint.width() / 2.0f, bay->anchor.y() + bay->footprint.height() / 2.0f); const ShipLayoutConfig salvageLayout = makeSingleModuleLayout("salvager"); const entt::entity ship = f.ships.spawn("salvage_ship", bayCenter, false, salvageLayout); f.admin.get(ship).value = bayCenter; CargoComponent& cargo = f.admin.get(ship); cargo.current = cargo.maxCapacity; // full cargo const int before = cargo.current; REQUIRE(before > 0); f.admin.get(ship).deliveryBay = bayId; f.salvageTick(); // One unit handed over from cargo into the bay's output buffer. REQUIRE(f.admin.get(ship).current == before - 1); const Building* bayAfter = findBuilding(f.state, bayId); REQUIRE(bayAfter != nullptr); REQUIRE(bayAfter->outputBuffer.items.size() == 1); REQUIRE(bayAfter->outputBuffer.items.front().type.id == "scrap"); } // --------------------------------------------------------------------------- // Collection range (per-module) // --------------------------------------------------------------------------- static int totalSalvageCurrent(EntityAdmin& admin, entt::entity ship) { return admin.hasAll(ship) ? admin.get(ship).current : 0; } TEST_CASE("SalvagerSystem: module does not collect scrap beyond its collection range", "[behavior]") { // collection_range_m_formula = "50"; scrap at distance 55 must not be collected. Fixture f; const ShipLayoutConfig salvageLayout = makeSingleModuleLayout("salvager"); const entt::entity ship = f.ships.spawn("salvage_ship", QVector2D(0.0f, 0.0f), false, salvageLayout); f.scraps.spawn(QVector2D(55.0f, 0.0f), 1, 100000); f.runSalvageCollect(); REQUIRE(f.admin.get(ship).current == 0); } TEST_CASE("SalvagerSystem: module collects scrap within its collection range", "[behavior]") { // collection_range_m_formula = "50"; scrap at distance 45 must be collected. Fixture f; const ShipLayoutConfig salvageLayout = makeSingleModuleLayout("salvager"); const entt::entity ship = f.ships.spawn("salvage_ship", QVector2D(0.0f, 0.0f), false, salvageLayout); f.scraps.spawn(QVector2D(45.0f, 0.0f), 1, 100000); f.runSalvageCollect(); REQUIRE(f.admin.get(ship).current == 1); } // --------------------------------------------------------------------------- // Collection rate (per-module cooldown) // --------------------------------------------------------------------------- TEST_CASE("SalvagerSystem: collection sets cooldown on module", "[behavior]") { Fixture f; const ShipLayoutConfig salvageLayout = makeSingleModuleLayout("salvager"); const entt::entity ship = f.ships.spawn("salvage_ship", QVector2D(0.0f, 0.0f), false, salvageLayout); f.scraps.spawn(QVector2D(0.0f, 0.0f), 1, 100000); // Starting a collection cycle sets the cooldown immediately; the scrap is not // collected until mid-beam (REQ-SHP-SALVAGE), so cargo is still empty now. f.salvageTick(); REQUIRE(f.admin.get(ship).current == 0); const SalvagerComponent& salvager = f.admin.get(firstSalvageChild(f.admin, ship)); REQUIRE(salvager.cooldownTicksRemaining == salvager.collectionIntervalTicks); } TEST_CASE("SalvagerSystem: module on cooldown does not collect scrap", "[behavior]") { Fixture f; const ShipLayoutConfig salvageLayout = makeSingleModuleLayout("salvager"); const entt::entity ship = f.ships.spawn("salvage_ship", QVector2D(0.0f, 0.0f), false, salvageLayout); f.scraps.spawn(QVector2D(0.0f, 0.0f), 1, 100000); f.admin.get(firstSalvageChild(f.admin, ship)).cooldownTicksRemaining = 10; f.runSalvageCollect(); REQUIRE(f.admin.get(ship).current == 0); } TEST_CASE("SalvagerSystem: module collects again after cooldown expires", "[behavior]") { Fixture f; const ShipLayoutConfig salvageLayout = makeSingleModuleLayout("salvager"); const entt::entity ship = f.ships.spawn("salvage_ship", QVector2D(0.0f, 0.0f), false, salvageLayout); const entt::entity sc = firstSalvageChild(f.admin, ship); f.scraps.spawn(QVector2D(0.0f, 0.0f), 1, 100000); f.runSalvageCollect(); REQUIRE(f.admin.get(ship).current == 1); // Shorten cooldown to 1 tick and place a second scrap. f.admin.get(sc).cooldownTicksRemaining = 1; f.scraps.spawn(QVector2D(0.0f, 0.0f), 1, 100000); // Once the cooldown expires the module starts another cycle and collects the // second scrap after the mid-beam delay. f.runSalvageCollect(); REQUIRE(f.admin.get(ship).current == 2); } // --------------------------------------------------------------------------- // Multiple salvage modules // --------------------------------------------------------------------------- TEST_CASE("SalvagerSystem: two salvage modules collect independently in same tick", "[behavior]") { Fixture f; const ShipLayoutConfig salvageLayout = makeTwoModuleLayout("salvager"); const entt::entity ship = f.ships.spawn("salvage_ship", QVector2D(0.0f, 0.0f), false, salvageLayout); f.scraps.spawn(QVector2D(0.0f, 0.0f), 1, 100000); f.scraps.spawn(QVector2D(0.0f, 0.0f), 1, 100000); f.runSalvageCollect(); REQUIRE(totalSalvageCurrent(f.admin, ship) == 2); } TEST_CASE("SalvagerSystem: second salvage module does not collect when first is on cooldown", "[behavior]") { // One module on cooldown, one ready: only the ready module collects. Fixture f; const ShipLayoutConfig salvageLayout = makeTwoModuleLayout("salvager"); const entt::entity ship = f.ships.spawn("salvage_ship", QVector2D(0.0f, 0.0f), false, salvageLayout); // Put the first salvage child on cooldown. entt::entity blocked = entt::null; f.admin.forEach( [&](entt::entity ce, SalvagerComponent& s, const ModuleOwnerComponent& o) { if (o.owner == ship && blocked == entt::null) { s.cooldownTicksRemaining = 99; blocked = ce; } }); f.scraps.spawn(QVector2D(0.0f, 0.0f), 1, 100000); f.scraps.spawn(QVector2D(0.0f, 0.0f), 1, 100000); f.runSalvageCollect(); // Only one module was ready, so only one scrap is collected. REQUIRE(totalSalvageCurrent(f.admin, ship) == 1); } // --------------------------------------------------------------------------- // Sensor range — spawn // --------------------------------------------------------------------------- TEST_CASE("SensorRange: sensorRange is populated from config formula at spawn", "[sensor]") { Fixture f; const entt::entity e = f.ships.spawn("interceptor", QVector2D(0.0f, 0.0f)); REQUIRE(f.admin.get(e).value_tiles == Approx(200.0f)); } // --------------------------------------------------------------------------- // Sensor range — AttackBehavior // --------------------------------------------------------------------------- TEST_CASE("SensorRange: player combat ship acquires enemy just inside sensor range", "[sensor]") { Fixture f; const entt::entity player = f.ships.spawn("interceptor", QVector2D(0.0f, 0.0f)); const entt::entity enemy = f.ships.spawn("interceptor", QVector2D(190.0f, 0.0f), /*isEnemy=*/true); f.decide(); REQUIRE(f.admin.get(player).currentTarget == enemy); } TEST_CASE("SensorRange: player combat ship ignores enemy just outside sensor range", "[sensor]") { Fixture f; const entt::entity player = f.ships.spawn("interceptor", QVector2D(0.0f, 0.0f)); f.ships.spawn("interceptor", QVector2D(210.0f, 0.0f), /*isEnemy=*/true); f.decide(); REQUIRE_FALSE(f.admin.get(player).currentTarget.has_value()); } TEST_CASE("SensorRange: enemy ship ignores player just outside sensor range", "[sensor]") { Fixture f; f.ships.spawn("interceptor", QVector2D(0.0f, 0.0f)); const entt::entity enemy = f.ships.spawn("interceptor", QVector2D(210.0f, 0.0f), /*isEnemy=*/true); f.decide(); REQUIRE_FALSE(f.admin.get(enemy).currentTarget.has_value()); } // --------------------------------------------------------------------------- // Sensor range — RetreatBehavior (unarmed ships flee threats) // --------------------------------------------------------------------------- TEST_CASE("SensorRange: repair ship retreats from enemy within sensor range", "[sensor]") { Fixture f; const QVector2D rallyPoint(-100.0f, 0.0f); f.ships.setRallyPoint(rallyPoint); const ShipLayoutConfig repairLayout = makeSingleModuleLayout("repair_tool"); const entt::entity repairShip = f.ships.spawn("repair_ship", QVector2D(0.0f, 0.0f), false, repairLayout); f.ships.spawn("interceptor", QVector2D(200.0f, 0.0f), /*isEnemy=*/true); f.decide(); REQUIRE(winnerOf(f.admin, repairShip) == BehaviorKind::Retreat); REQUIRE(intent(f.admin, repairShip).target.x() == Approx(rallyPoint.x())); } TEST_CASE("SensorRange: repair ship does not retreat from enemy beyond sensor range", "[sensor]") { Fixture f; const ShipLayoutConfig repairLayout = makeSingleModuleLayout("repair_tool"); const entt::entity repairShip = f.ships.spawn("repair_ship", QVector2D(0.0f, 0.0f), false, repairLayout); f.ships.spawn("interceptor", QVector2D(300.0f, 0.0f), /*isEnemy=*/true); f.decide(); // Beyond sensor range the enemy is no threat, so the repair ship does not flee; // with nothing to repair it holds with the fleet (REQ-SHP-STANDBY) rather than // retreating or charging the enemy. REQUIRE(winnerOf(f.admin, repairShip) != BehaviorKind::Retreat); REQUIRE(winnerOf(f.admin, repairShip) == BehaviorKind::Standby); } TEST_CASE("SensorRange: repair ship does not acquire damaged ally beyond sensor range", "[sensor]") { Fixture f; const ShipLayoutConfig repairLayout = makeSingleModuleLayout("repair_tool"); const entt::entity repairShip = f.ships.spawn("repair_ship", QVector2D(0.0f, 0.0f), false, repairLayout); const entt::entity friendly = f.ships.spawn("interceptor", QVector2D(300.0f, 0.0f)); f.admin.get(friendly).hp = f.admin.get(friendly).maxHp * 0.5f; f.decide(); REQUIRE_FALSE(f.admin.get(repairShip).currentTarget.has_value()); } // --------------------------------------------------------------------------- // Sensor range — SalvageScrapBehavior // --------------------------------------------------------------------------- TEST_CASE("SensorRange: salvage ship ignores scrap beyond sensor range", "[sensor]") { Fixture f; const ShipLayoutConfig salvageLayout = makeSingleModuleLayout("salvager"); const entt::entity ship = f.ships.spawn("salvage_ship", QVector2D(0.0f, 0.0f), false, salvageLayout); f.scraps.spawn(QVector2D(300.0f, 0.0f), 1, 100000); f.decide(); REQUIRE_FALSE(f.admin.get(ship).debrisTarget.has_value()); REQUIRE(intent(f.admin, ship).target.x() > pos(f.admin, ship).value.x()); } // --------------------------------------------------------------------------- // Orbit movement (REQ-SHP-ORBIT) // --------------------------------------------------------------------------- TEST_CASE("Orbit: combat ship's intent carries the target center and orbit radius", "[orbit]") { Fixture f; const entt::entity player = f.ships.spawn("interceptor", QVector2D(0.0f, 0.0f)); const entt::entity enemy = f.ships.spawn("interceptor", QVector2D(10.0f, 0.0f), /*isEnemy=*/true); f.decide(); REQUIRE(winnerOf(f.admin, player) == BehaviorKind::Attack); const float orbitRadius = f.admin.get(player).orbitRadius_tiles; REQUIRE(orbitRadius > 0.0f); // The intent carries the enemy center and the orbit radius; the orbit point is // resolved later by MovementIntentSystem. REQUIRE(intent(f.admin, player).target == pos(f.admin, enemy).value); REQUIRE(intent(f.admin, player).orbitRadius_tiles == Approx(orbitRadius)); } TEST_CASE("Orbit: rally ship orbits the rally point at the configured rally radius", "[orbit]") { Fixture f; const QVector2D rallyPoint(-50.0f, 0.0f); f.ships.setRallyPoint(rallyPoint); const entt::entity player = f.ships.spawn("interceptor", QVector2D(0.0f, 0.0f)); f.decide(); REQUIRE(winnerOf(f.admin, player) == BehaviorKind::Rally); const float orbitRadius = f.admin.get(player).orbitRadius_tiles; REQUIRE(orbitRadius == Approx(static_cast(f.cfg.world.rallyOrbitRadius_tiles))); REQUIRE(intent(f.admin, player).target == rallyPoint); REQUIRE(intent(f.admin, player).orbitRadius_tiles == Approx(orbitRadius)); } TEST_CASE("Orbit: combat ship settles near the orbit radius and circles a stationary target", "[orbit]") { Fixture f; const QVector2D enemyPos(80.0f, 0.0f); const entt::entity player = f.ships.spawn("interceptor", QVector2D(0.0f, 0.0f)); const entt::entity enemy = f.ships.spawn("interceptor", enemyPos, /*isEnemy=*/true); const float orbitRadius = f.admin.get(player).orbitRadius_tiles; REQUIRE(orbitRadius > 0.0f); REQUIRE(orbitRadius < enemyPos.x()); // ship must close in to reach the orbit // Run many full ticks, pinning the enemy in place so it is a stationary target. float angleBefore = 0.0f; for (int i = 0; i < 1200; ++i) { f.admin.get(enemy).value = enemyPos; // keep target fixed f.admin.get(enemy).velocity_tpt = QVector2D(0.0f, 0.0f); if (i == 800) { angleBefore = std::atan2(pos(f.admin, player).value.y() - enemyPos.y(), pos(f.admin, player).value.x() - enemyPos.x()); } f.runBehaviorTick(); } const float angleAfter = std::atan2(pos(f.admin, player).value.y() - enemyPos.y(), pos(f.admin, player).value.x() - enemyPos.x()); // Settled close to the orbit radius (chasing a moving lead point oscillates a bit). const float dist = (pos(f.admin, player).value - enemyPos).length(); REQUIRE(dist == Approx(orbitRadius).margin(0.35f * orbitRadius)); // The ship is circling: its angular position around the target has moved. REQUIRE(std::abs(angleAfter - angleBefore) > 0.05f); } TEST_CASE("OrbitMath: sign mirrors the orbit destination across the radial", "[orbit]") { const QVector2D center(0.0f, 0.0f); const QVector2D shipPos(5.0f, 0.0f); const float radius = 5.0f; const QVector2D ccw = OrbitMath::computeOrbitDestination(shipPos, center, radius, +1.0f); const QVector2D cw = OrbitMath::computeOrbitDestination(shipPos, center, radius, -1.0f); // Both lie exactly on the orbit circle. REQUIRE((ccw - center).length() == Approx(radius)); REQUIRE((cw - center).length() == Approx(radius)); // Opposite tangential lead: CCW leads to +y, CW to -y; x components match. REQUIRE(ccw.y() > 0.0f); REQUIRE(cw.y() < 0.0f); REQUIRE(ccw.y() == Approx(-cw.y())); REQUIRE(ccw.x() == Approx(cw.x())); } TEST_CASE("OrbitMath: orbit sign follows the ship's tangential velocity", "[orbit]") { const QVector2D center(0.0f, 0.0f); const QVector2D shipPos(5.0f, 0.0f); // radial points +x // Tangential +y velocity is counter-clockwise around the center → +1. REQUIRE(OrbitMath::resolveOrbitSign(shipPos, center, QVector2D(0.0f, 1.0f)) == Approx(1.0f)); // Tangential -y velocity is clockwise → -1. REQUIRE(OrbitMath::resolveOrbitSign(shipPos, center, QVector2D(0.0f, -1.0f)) == Approx(-1.0f)); // Radial velocity (toward/away from center) is ambiguous → fallback +1. REQUIRE(OrbitMath::resolveOrbitSign(shipPos, center, QVector2D(-1.0f, 0.0f)) == Approx(1.0f)); // Zero velocity (e.g. freshly spawned) → fallback +1. REQUIRE(OrbitMath::resolveOrbitSign(shipPos, center, QVector2D(0.0f, 0.0f)) == Approx(1.0f)); } TEST_CASE("OrbitMath: orbit sense uses velocity relative to a moving center", "[orbit]") { const QVector2D center(0.0f, 0.0f); const QVector2D shipPos(5.0f, 0.0f); // radial points +x // Two ships orbiting each other can translate in parallel: the ship and the // center share the same velocity, so relative velocity cancels → fallback +1 // (both ships agree on the sign and break into a real mutual orbit). const QVector2D sharedVelocity(0.0f, 3.0f); REQUIRE(OrbitMath::resolveOrbitSign(shipPos, center, sharedVelocity, sharedVelocity) == Approx(1.0f)); // A moving center's own motion is removed: the ship is stationary while the // center drifts +y, so relative motion is -y → clockwise → -1. REQUIRE(OrbitMath::resolveOrbitSign(shipPos, center, QVector2D(0.0f, 0.0f), QVector2D(0.0f, 3.0f)) == Approx(-1.0f)); }