getAllDebrisInfo and collectOne only ever touched EntityAdmin — DebrisSystem holds nothing else — so they become free functions over the registry. That lets AiSystem, SalvagerSystem and SalvageScrapEvaluator drop their DebrisSystem& parameters entirely; SalvagerSystem already held the admin, and the other two were handed it alongside. No system in lib/ecs/system takes another system now. Every tick signature names the data it works on: the registry, the factory state, or both. DebrisSystem keeps spawn, tickDespawn and consume — the first two are genuine tick behaviour rather than lookups. Verified with a golden-checksum capture before and after — all four sample ticks identical. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YHcUerKAZKWNvSKJxYKbnG
1426 lines
56 KiB
C++
1426 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 (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<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));
|
|
}
|