Files
dota_factory/src/test/BehaviorSystemTest.cpp

1427 lines
56 KiB
C++

#include "catch.hpp"
#include "FactoryQueries.h"
#include <cmath>
#include <random>
#include <QPoint>
#include <QSize>
#include <QVector2D>
#include "AdvanceBehavior.h"
#include "AiSystem.h"
#include "AttackBehavior.h"
#include "BehaviorKind.h"
#include "BeltSystem.h"
#include "Building.h"
#include "BuildingSystem.h"
#include "ConstructionSystem.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 = makeFactoryState(cfg);
BeltSystem belts;
BuildingId nextBuildingId;
int stock;
std::mt19937 rng;
EntityAdmin admin;
BuildingSystem buildings;
ConstructionSystem construction;
ShipSystem ships;
AiSystem ai;
SalvagerSystem salvager;
RepairSystem repair;
MovementIntentSystem movementIntent;
DynamicBodySystem dynamicBody;
DebrisSystem scraps;
Tick tick;
std::vector<BeamFiredEvent> beamEvents;
explicit Fixture()
: cfg(loadTestConfig())
, belts(cfg.world.beltSpeed_tps)
, nextBuildingId(1)
, stock(0)
, rng(42)
, buildings(cfg, belts,
[this]() { return nextBuildingId++; },
[this](int n) { stock += n; },
[](const std::string&, QVector2D, const std::optional<ShipLayoutConfig>&) {},
[](const std::string&) -> bool { return true; },
rng)
, construction(cfg)
, 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);
}
// World mutation: collection/delivery and healing.
void runModules()
{
beamEvents.clear();
salvager.tick(tick, 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, 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<SalvagerComponent, ModuleOwnerComponent>(
[&](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<RepairToolComponent, ModuleOwnerComponent>(
[&](entt::entity ce, const RepairToolComponent&, const ModuleOwnerComponent& o)
{
if (o.owner == ship && result == entt::null) { result = ce; }
});
return result;
}
static std::vector<entt::entity> allRepairChildren(EntityAdmin& admin, entt::entity ship)
{
std::vector<entt::entity> result;
admin.forEach<RepairToolComponent, ModuleOwnerComponent>(
[&](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<MovementIntentComponent>(e);
}
static BehaviorKind winnerOf(EntityAdmin& a, entt::entity e)
{
return a.get<SelectedBehaviorComponent>(e).winner;
}
static const HealthComponent& health(EntityAdmin& a, entt::entity e)
{
return a.get<HealthComponent>(e);
}
static const PositionComponent& pos(EntityAdmin& a, entt::entity e)
{
return a.get<PositionComponent>(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<MovementIntentComponent>(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<DynamicBodyComponent>(e).maxSpeed_tpt;
f.admin.get<MovementIntentComponent>(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<DynamicBodyComponent>(e).maxSpeed_tpt;
const QVector2D target(speed * 0.5f, 0.0f);
f.admin.get<MovementIntentComponent>(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<HealthComponent>(e).hp = f.admin.get<HealthComponent>(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<HealthComponent>(e).hp = f.admin.get<HealthComponent>(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<HealthComponent>(player).hp = f.admin.get<HealthComponent>(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<HealthComponent>(enemy).hp = f.admin.get<HealthComponent>(enemy).maxHp * 0.05f;
f.decide();
REQUIRE_FALSE(f.admin.hasAll<RetreatBehavior>(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<AttackBehavior>(player));
const AttackBehavior& attack = f.admin.get<AttackBehavior>(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<AttackBehavior>(e1));
REQUIRE_FALSE(f.admin.get<AttackBehavior>(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<AttackBehavior>(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<AttackBehavior>(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<AttackBehavior>(claimant).currentTarget = enemyA;
f.decide();
REQUIRE(f.admin.get<AttackBehavior>(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<AttackBehavior>(player).currentTarget = enemyA;
f.decide();
REQUIRE(f.admin.get<AttackBehavior>(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<AttackBehavior>(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<float>(2 + i)));
f.admin.get<AttackBehavior>(other).currentTarget = enemyA;
}
f.decide();
REQUIRE(f.admin.get<AttackBehavior>(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<AttackBehavior>(enemy));
const AttackBehavior& attack = f.admin.get<AttackBehavior>(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<QPoint> 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<HealthComponent>(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<HealthComponent>(friendly).hp = f.admin.get<HealthComponent>(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<RepairBehavior>(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<QPoint> 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<HealthComponent>(friendly).maxHp * 0.5f;
f.admin.get<HealthComponent>(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<HealthComponent>(friendly).hp = f.admin.get<HealthComponent>(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<HealthComponent>(friendly).maxHp * 0.5f;
f.admin.get<HealthComponent>(friendly).hp = initHp;
f.decide();
f.runRepairHeal();
const entt::entity rc = firstRepairChild(f.admin, repairShip);
REQUIRE(f.admin.isValid(rc));
REQUIRE(f.admin.get<RepairToolComponent>(rc).currentTarget.has_value());
REQUIRE(*f.admin.get<RepairToolComponent>(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<HealthComponent>(outOfRange).maxHp * 0.5f;
const float fallbackInitHp = f.admin.get<HealthComponent>(fallback).maxHp * 0.5f;
f.admin.get<HealthComponent>(outOfRange).hp = outInitHp;
f.admin.get<HealthComponent>(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<RepairToolComponent>(rc).currentTarget = outOfRange;
f.runRepairHeal();
REQUIRE(f.admin.get<RepairToolComponent>(rc).currentTarget.has_value());
REQUIRE(*f.admin.get<RepairToolComponent>(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<HealthComponent>(healed).hp = f.admin.get<HealthComponent>(healed).maxHp;
const float fallbackInitHp = f.admin.get<HealthComponent>(fallback).maxHp * 0.5f;
f.admin.get<HealthComponent>(fallback).hp = fallbackInitHp;
const entt::entity rc = firstRepairChild(f.admin, repairShip);
f.admin.get<RepairToolComponent>(rc).currentTarget = healed;
f.runRepairHeal();
REQUIRE(*f.admin.get<RepairToolComponent>(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<HealthComponent>(fallback).maxHp * 0.5f;
f.admin.get<HealthComponent>(fallback).hp = fallbackInitHp;
const entt::entity rc = firstRepairChild(f.admin, repairShip);
f.admin.get<RepairToolComponent>(rc).currentTarget = gone;
f.ships.despawn(gone);
f.runRepairHeal();
REQUIRE(*f.admin.get<RepairToolComponent>(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<HealthComponent>(outOfRange).maxHp * 0.5f;
f.admin.get<HealthComponent>(outOfRange).hp = initHp;
const entt::entity rc = firstRepairChild(f.admin, repairShip);
f.admin.get<RepairToolComponent>(rc).currentTarget = outOfRange;
f.runRepairHeal();
REQUIRE_FALSE(f.admin.get<RepairToolComponent>(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<HealthComponent>(targetA).maxHp * 0.5f;
f.admin.get<HealthComponent>(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<entt::entity> children = allRepairChildren(f.admin, repairShip);
REQUIRE(children.size() == 2);
for (const entt::entity child : children)
{
REQUIRE(f.admin.get<RepairToolComponent>(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<HealthComponent>(healed).hp = f.admin.get<HealthComponent>(healed).maxHp;
const float initHp = f.admin.get<HealthComponent>(targetB).maxHp * 0.5f;
f.admin.get<HealthComponent>(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<RepairToolComponent>(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<entt::entity> children = allRepairChildren(f.admin, repairShip);
REQUIRE(children.size() == 2);
for (const entt::entity child : children)
{
REQUIRE(f.admin.get<RepairToolComponent>(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<RepairToolComponent>(moduleEntity, rt);
f.admin.addComponent<ModuleOwnerComponent>(moduleEntity, ModuleOwnerComponent{ownerShip});
// Must not crash; no damaged friendly in range, so no target is set.
f.runRepairHeal();
REQUIRE_FALSE(f.admin.get<RepairToolComponent>(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<QPoint> 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<HqProxyComponent>(hq);
f.decide();
f.runRepairHeal();
REQUIRE(f.admin.get<HealthComponent>(hq).hp == Approx(100.0f));
const entt::entity rc = firstRepairChild(f.admin, repairShip);
REQUIRE_FALSE(f.admin.get<RepairToolComponent>(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<QPoint> 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<HealthComponent>(station).hp > 100.0f);
const entt::entity rc = firstRepairChild(f.admin, repairShip);
REQUIRE(*f.admin.get<RepairToolComponent>(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<SalvageScrapBehavior>(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<CargoComponent>(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(f.state, BuildingType::SalvageBay,
QPoint(-4, 0), Rotation::East, 0).value();
Tick t = 0;
for (int i = 0; i < 500; ++i)
{
f.construction.tick(f.state, f.belts, 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<CargoComponent>(ship);
cargo.current = cargo.maxCapacity; // full cargo
}
f.decide();
REQUIRE(winnerOf(f.admin, ship) == BehaviorKind::DeliverScrap);
REQUIRE(f.admin.get<DeliverScrapBehavior>(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(f.state, BuildingType::SalvageBay,
QPoint(-4, 0), Rotation::East, 0).value();
Tick t = 0;
for (int i = 0; i < 500; ++i)
{
f.construction.tick(f.state, f.belts, 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, onto the single
// scrap buffer the bay holds (REQ-BLD-SALVAGE-BAY).
REQUIRE(bay->outputBuffer.caps.at(ItemType{"scrap"}) == 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<PositionComponent>(ship).value = bayCenter;
CargoComponent& cargo = f.admin.get<CargoComponent>(ship);
cargo.current = cargo.maxCapacity; // full cargo
const int before = cargo.current;
REQUIRE(before > 0);
f.admin.get<DeliverScrapBehavior>(ship).deliveryBay = bayId;
f.salvageTick();
// One unit handed over from cargo into the bay's output buffer.
REQUIRE(f.admin.get<CargoComponent>(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<CargoComponent>(ship)
? admin.get<CargoComponent>(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<CargoComponent>(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<CargoComponent>(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<CargoComponent>(ship).current == 0);
const SalvagerComponent& salvager =
f.admin.get<SalvagerComponent>(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<SalvagerComponent>(firstSalvageChild(f.admin, ship)).cooldownTicksRemaining = 10;
f.runSalvageCollect();
REQUIRE(f.admin.get<CargoComponent>(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<CargoComponent>(ship).current == 1);
// Shorten cooldown to 1 tick and place a second scrap.
f.admin.get<SalvagerComponent>(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<CargoComponent>(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<SalvagerComponent, ModuleOwnerComponent>(
[&](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<SensorRangeComponent>(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<AttackBehavior>(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<AttackBehavior>(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<AttackBehavior>(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<HealthComponent>(friendly).hp = f.admin.get<HealthComponent>(friendly).maxHp * 0.5f;
f.decide();
REQUIRE_FALSE(f.admin.get<RepairBehavior>(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<SalvageScrapBehavior>(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<AttackBehavior>(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<RallyBehavior>(player).orbitRadius_tiles;
REQUIRE(orbitRadius == Approx(static_cast<float>(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<AttackBehavior>(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<PositionComponent>(enemy).value = enemyPos; // keep target fixed
f.admin.get<DynamicBodyComponent>(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));
}