switch to ECS architecture
This commit is contained in:
@@ -10,9 +10,10 @@
|
||||
#include "BuildingSystem.h"
|
||||
#include "BuildingType.h"
|
||||
#include "ConfigLoader.h"
|
||||
#include "EcsComponents.h"
|
||||
#include "EntityAdmin.h"
|
||||
#include "MovementSystem.h"
|
||||
#include "Rotation.h"
|
||||
#include "Scrap.h"
|
||||
#include "ScrapSystem.h"
|
||||
#include "Ship.h"
|
||||
#include "ShipSystem.h"
|
||||
@@ -34,6 +35,7 @@ struct Fixture
|
||||
EntityId nextId;
|
||||
int stock;
|
||||
std::mt19937 rng;
|
||||
EntityAdmin admin;
|
||||
BuildingSystem buildings;
|
||||
ShipSystem ships;
|
||||
AiSystem ai;
|
||||
@@ -52,8 +54,8 @@ struct Fixture
|
||||
[this](int n) { stock += n; },
|
||||
[](const std::string&, QVector2D, const std::optional<ShipLayoutConfig>&) {},
|
||||
rng)
|
||||
, ships(cfg, [this]() { return nextId++; })
|
||||
, scraps([this]() { return nextId++; })
|
||||
, ships(cfg, admin)
|
||||
, scraps(admin)
|
||||
, tick(0)
|
||||
{
|
||||
}
|
||||
@@ -62,15 +64,31 @@ struct Fixture
|
||||
void runBehaviorTick()
|
||||
{
|
||||
ships.clearMovementIntents();
|
||||
ai.tickHomeReturn(ships);
|
||||
ai.tickThreatResponse(ships, buildings);
|
||||
ai.tickRepairBehavior(ships, buildings);
|
||||
ai.tickScrapCollector(ships, scraps, buildings);
|
||||
movement.tick(ships);
|
||||
ai.tickHomeReturn(admin);
|
||||
ai.tickThreatResponse(admin, buildings);
|
||||
ai.tickRepairBehavior(admin, buildings);
|
||||
ai.tickScrapCollector(admin, scraps, buildings);
|
||||
movement.tick(admin);
|
||||
++tick;
|
||||
}
|
||||
};
|
||||
|
||||
// Helpers to read ECS data for a ship entity.
|
||||
static const MovementIntent& intent(EntityAdmin& a, entt::entity e)
|
||||
{
|
||||
return a.get<MovementIntent>(e);
|
||||
}
|
||||
|
||||
static const Health& health(EntityAdmin& a, entt::entity e)
|
||||
{
|
||||
return a.get<Health>(e);
|
||||
}
|
||||
|
||||
static const Position& pos(EntityAdmin& a, entt::entity e)
|
||||
{
|
||||
return a.get<Position>(e);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// clearMovementIntents
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -79,65 +97,45 @@ TEST_CASE("BehaviorSystem: clearMovementIntents resets all ships to priority 0",
|
||||
"[behavior]")
|
||||
{
|
||||
Fixture f;
|
||||
const EntityId id = f.ships.spawn("interceptor", 1, QVector2D(0.0f, 0.0f));
|
||||
|
||||
// Manually write a non-zero intent.
|
||||
f.ships.forEach([](Ship& s) {
|
||||
s.intent = MovementIntent{3, QVector2D(10.0f, 0.0f)};
|
||||
});
|
||||
const entt::entity e = f.ships.spawn("interceptor", 1, QVector2D(0.0f, 0.0f));
|
||||
|
||||
f.admin.get<MovementIntent>(e) = MovementIntent{3, QVector2D(10.0f, 0.0f)};
|
||||
f.ships.clearMovementIntents();
|
||||
|
||||
const Ship* s = f.ships.findShip(id);
|
||||
REQUIRE(s != nullptr);
|
||||
REQUIRE(s->intent.priority == 0);
|
||||
REQUIRE(intent(f.admin, e).priority == 0);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// tickMovement
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// With facing=0 and target due east, main thrust drives the ship east. The test
|
||||
// config uses very high thrust so the ship reaches maxSpeedPerTick in one tick.
|
||||
TEST_CASE("BehaviorSystem: tickMovement advances ship by maxSpeedPerTick toward target",
|
||||
"[behavior]")
|
||||
{
|
||||
Fixture f;
|
||||
const EntityId id = f.ships.spawn("interceptor", 1, QVector2D(0.0f, 0.0f));
|
||||
const entt::entity e = f.ships.spawn("interceptor", 1, QVector2D(0.0f, 0.0f));
|
||||
|
||||
const float speed = f.ships.findShip(id)->maxSpeedPerTick;
|
||||
const QVector2D target(100.0f, 0.0f);
|
||||
const float speed = f.admin.get<ShipDynamics>(e).maxSpeedPerTick;
|
||||
f.admin.get<MovementIntent>(e) = MovementIntent{1, QVector2D(100.0f, 0.0f)};
|
||||
f.movement.tick(f.admin);
|
||||
|
||||
f.ships.forEach([&target](Ship& s) {
|
||||
s.intent = MovementIntent{1, target};
|
||||
});
|
||||
f.movement.tick(f.ships);
|
||||
|
||||
const Ship* s = f.ships.findShip(id);
|
||||
REQUIRE(s->position.x() == Approx(speed));
|
||||
REQUIRE(s->position.y() == Approx(0.0f));
|
||||
REQUIRE(pos(f.admin, e).value.x() == Approx(speed));
|
||||
REQUIRE(pos(f.admin, e).value.y() == Approx(0.0f));
|
||||
}
|
||||
|
||||
// With very high maneuvering thrust the stopping distance is ~0, so desiredSpeed
|
||||
// still exceeds maxSpeedPerTick and the snap-to-target branch fires.
|
||||
TEST_CASE("BehaviorSystem: tickMovement stops exactly at target without overshoot",
|
||||
"[behavior]")
|
||||
{
|
||||
Fixture f;
|
||||
const EntityId id = f.ships.spawn("interceptor", 1, QVector2D(0.0f, 0.0f));
|
||||
const entt::entity e = f.ships.spawn("interceptor", 1, QVector2D(0.0f, 0.0f));
|
||||
|
||||
// Place target closer than one tick's travel.
|
||||
const float speed = f.ships.findShip(id)->maxSpeedPerTick;
|
||||
const float speed = f.admin.get<ShipDynamics>(e).maxSpeedPerTick;
|
||||
const QVector2D target(speed * 0.5f, 0.0f);
|
||||
f.admin.get<MovementIntent>(e) = MovementIntent{1, target};
|
||||
f.movement.tick(f.admin);
|
||||
|
||||
f.ships.forEach([&target](Ship& s) {
|
||||
s.intent = MovementIntent{1, target};
|
||||
});
|
||||
f.movement.tick(f.ships);
|
||||
|
||||
const Ship* s = f.ships.findShip(id);
|
||||
REQUIRE(s->position.x() == Approx(target.x()));
|
||||
REQUIRE(s->position.y() == Approx(target.y()));
|
||||
REQUIRE(pos(f.admin, e).value.x() == Approx(target.x()));
|
||||
REQUIRE(pos(f.admin, e).value.y() == Approx(target.y()));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -148,63 +146,49 @@ TEST_CASE("BehaviorSystem: tickHomeReturn does nothing when HP is above threshol
|
||||
"[behavior]")
|
||||
{
|
||||
Fixture f;
|
||||
const EntityId id = f.ships.spawn("interceptor", 1, QVector2D(0.0f, 0.0f));
|
||||
|
||||
f.ships.forEach([](Ship& s) {
|
||||
s.homeReturn = HomeReturn{0.3f, QVector2D(-10.0f, 0.0f)};
|
||||
s.hp = s.maxHp; // full HP — above threshold
|
||||
});
|
||||
const entt::entity e = f.ships.spawn("interceptor", 1, QVector2D(0.0f, 0.0f));
|
||||
f.admin.addComponent<HomeReturn>(e, HomeReturn{0.3f, QVector2D(-10.0f, 0.0f)});
|
||||
f.admin.get<Health>(e).hp = f.admin.get<Health>(e).maxHp; // full HP
|
||||
|
||||
f.ships.clearMovementIntents();
|
||||
f.ai.tickHomeReturn(f.ships);
|
||||
f.ai.tickHomeReturn(f.admin);
|
||||
|
||||
REQUIRE(f.ships.findShip(id)->intent.priority == 0);
|
||||
REQUIRE(intent(f.admin, e).priority == 0);
|
||||
}
|
||||
|
||||
TEST_CASE("BehaviorSystem: tickHomeReturn writes priority-4 intent toward homePos when HP is low",
|
||||
"[behavior]")
|
||||
{
|
||||
Fixture f;
|
||||
const EntityId id = f.ships.spawn("interceptor", 1, QVector2D(0.0f, 0.0f));
|
||||
const entt::entity e = f.ships.spawn("interceptor", 1, QVector2D(0.0f, 0.0f));
|
||||
const QVector2D homePos(-10.0f, 0.0f);
|
||||
|
||||
f.ships.forEach([&homePos](Ship& s) {
|
||||
s.homeReturn = HomeReturn{0.5f, homePos};
|
||||
s.hp = s.maxHp * 0.2f; // below 50% threshold
|
||||
});
|
||||
f.admin.addComponent<HomeReturn>(e, HomeReturn{0.5f, homePos});
|
||||
f.admin.get<Health>(e).hp = f.admin.get<Health>(e).maxHp * 0.2f; // below threshold
|
||||
|
||||
f.ships.clearMovementIntents();
|
||||
f.ai.tickHomeReturn(f.ships);
|
||||
f.ai.tickHomeReturn(f.admin);
|
||||
|
||||
const Ship* s = f.ships.findShip(id);
|
||||
REQUIRE(s->intent.priority == 4);
|
||||
REQUIRE(s->intent.target.x() == Approx(homePos.x()));
|
||||
REQUIRE(intent(f.admin, e).priority == 4);
|
||||
REQUIRE(intent(f.admin, e).target.x() == Approx(homePos.x()));
|
||||
}
|
||||
|
||||
TEST_CASE("BehaviorSystem: tickHomeReturn priority-4 beats tickThreatResponse priority-3",
|
||||
"[behavior]")
|
||||
{
|
||||
Fixture f;
|
||||
// Player ship with both homeReturn (low HP) and an enemy in range.
|
||||
const EntityId playerId = f.ships.spawn("interceptor", 1, QVector2D(0.0f, 0.0f));
|
||||
const entt::entity player = f.ships.spawn("interceptor", 1, QVector2D(0.0f, 0.0f));
|
||||
f.ships.spawn("interceptor", 1, QVector2D(5.0f, 0.0f), /*isEnemy=*/true);
|
||||
|
||||
const QVector2D homePos(-50.0f, 0.0f);
|
||||
f.ships.forEach([&homePos, playerId](Ship& s) {
|
||||
if (s.id == playerId)
|
||||
{
|
||||
s.homeReturn = HomeReturn{0.5f, homePos};
|
||||
s.hp = s.maxHp * 0.1f;
|
||||
}
|
||||
});
|
||||
f.admin.addComponent<HomeReturn>(player, HomeReturn{0.5f, homePos});
|
||||
f.admin.get<Health>(player).hp = f.admin.get<Health>(player).maxHp * 0.1f;
|
||||
|
||||
f.ships.clearMovementIntents();
|
||||
f.ai.tickHomeReturn(f.ships);
|
||||
f.ai.tickThreatResponse(f.ships, f.buildings);
|
||||
f.ai.tickHomeReturn(f.admin);
|
||||
f.ai.tickThreatResponse(f.admin, f.buildings);
|
||||
|
||||
const Ship* s = f.ships.findShip(playerId);
|
||||
REQUIRE(s->intent.priority == 4);
|
||||
REQUIRE(s->intent.target.x() == Approx(homePos.x()));
|
||||
REQUIRE(intent(f.admin, player).priority == 4);
|
||||
REQUIRE(intent(f.admin, player).target.x() == Approx(homePos.x()));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -215,48 +199,44 @@ TEST_CASE("BehaviorSystem: player combat ship acquires nearest enemy ship in ran
|
||||
"[behavior]")
|
||||
{
|
||||
Fixture f;
|
||||
const EntityId playerId = f.ships.spawn("interceptor", 1, QVector2D(0.0f, 0.0f));
|
||||
// Spawn enemy within attack range (150 tile units).
|
||||
const EntityId enemyId = f.ships.spawn("interceptor", 1, QVector2D(10.0f, 0.0f),
|
||||
/*isEnemy=*/true);
|
||||
const entt::entity player = f.ships.spawn("interceptor", 1, QVector2D(0.0f, 0.0f));
|
||||
const entt::entity enemy = f.ships.spawn("interceptor", 1, QVector2D(10.0f, 0.0f),
|
||||
/*isEnemy=*/true);
|
||||
|
||||
f.ships.clearMovementIntents();
|
||||
f.ai.tickThreatResponse(f.ships, f.buildings);
|
||||
f.ai.tickThreatResponse(f.admin, f.buildings);
|
||||
|
||||
const Ship* player = f.ships.findShip(playerId);
|
||||
REQUIRE(player->threatResponse.has_value());
|
||||
REQUIRE(player->threatResponse->currentTarget.has_value());
|
||||
REQUIRE(*player->threatResponse->currentTarget == enemyId);
|
||||
REQUIRE(f.admin.hasAll<ThreatResponse>(player));
|
||||
const ThreatResponse& tr = f.admin.get<ThreatResponse>(player);
|
||||
REQUIRE(tr.currentTarget.has_value());
|
||||
REQUIRE(*tr.currentTarget == enemy);
|
||||
}
|
||||
|
||||
TEST_CASE("BehaviorSystem: player combat ship does not target friendly ships",
|
||||
"[behavior]")
|
||||
{
|
||||
Fixture f;
|
||||
const EntityId id1 = f.ships.spawn("interceptor", 1, QVector2D(0.0f, 0.0f));
|
||||
f.ships.spawn("interceptor", 1, QVector2D(5.0f, 0.0f)); // also player (isEnemy=false)
|
||||
const entt::entity e1 = f.ships.spawn("interceptor", 1, QVector2D(0.0f, 0.0f));
|
||||
f.ships.spawn("interceptor", 1, QVector2D(5.0f, 0.0f)); // also player
|
||||
|
||||
f.ships.clearMovementIntents();
|
||||
f.ai.tickThreatResponse(f.ships, f.buildings);
|
||||
f.ai.tickThreatResponse(f.admin, f.buildings);
|
||||
|
||||
const Ship* s = f.ships.findShip(id1);
|
||||
REQUIRE(s->threatResponse.has_value());
|
||||
REQUIRE_FALSE(s->threatResponse->currentTarget.has_value());
|
||||
REQUIRE(f.admin.hasAll<ThreatResponse>(e1));
|
||||
REQUIRE_FALSE(f.admin.get<ThreatResponse>(e1).currentTarget.has_value());
|
||||
}
|
||||
|
||||
TEST_CASE("BehaviorSystem: player combat ship ignores enemy beyond engagement range",
|
||||
"[behavior]")
|
||||
{
|
||||
Fixture f;
|
||||
const EntityId playerId = f.ships.spawn("interceptor", 1, QVector2D(0.0f, 0.0f));
|
||||
// Place enemy far beyond engagement range (150 tile units).
|
||||
const entt::entity player = f.ships.spawn("interceptor", 1, QVector2D(0.0f, 0.0f));
|
||||
f.ships.spawn("interceptor", 1, QVector2D(500.0f, 0.0f), /*isEnemy=*/true);
|
||||
|
||||
f.ships.clearMovementIntents();
|
||||
f.ai.tickThreatResponse(f.ships, f.buildings);
|
||||
f.ai.tickThreatResponse(f.admin, f.buildings);
|
||||
|
||||
const Ship* s = f.ships.findShip(playerId);
|
||||
REQUIRE_FALSE(s->threatResponse->currentTarget.has_value());
|
||||
REQUIRE_FALSE(f.admin.get<ThreatResponse>(player).currentTarget.has_value());
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -267,32 +247,31 @@ TEST_CASE("BehaviorSystem: enemy ship acquires nearest player ship in range",
|
||||
"[behavior]")
|
||||
{
|
||||
Fixture f;
|
||||
const EntityId playerId = f.ships.spawn("interceptor", 1, QVector2D(0.0f, 0.0f));
|
||||
const EntityId enemyId = f.ships.spawn("interceptor", 1, QVector2D(10.0f, 0.0f),
|
||||
/*isEnemy=*/true);
|
||||
const entt::entity player = f.ships.spawn("interceptor", 1, QVector2D(0.0f, 0.0f));
|
||||
const entt::entity enemy = f.ships.spawn("interceptor", 1, QVector2D(10.0f, 0.0f),
|
||||
/*isEnemy=*/true);
|
||||
|
||||
f.ships.clearMovementIntents();
|
||||
f.ai.tickThreatResponse(f.ships, f.buildings);
|
||||
f.ai.tickThreatResponse(f.admin, f.buildings);
|
||||
|
||||
const Ship* enemy = f.ships.findShip(enemyId);
|
||||
REQUIRE(enemy->threatResponse.has_value());
|
||||
REQUIRE(enemy->threatResponse->currentTarget.has_value());
|
||||
REQUIRE(*enemy->threatResponse->currentTarget == playerId);
|
||||
REQUIRE(f.admin.hasAll<ThreatResponse>(enemy));
|
||||
const ThreatResponse& tr = f.admin.get<ThreatResponse>(enemy);
|
||||
REQUIRE(tr.currentTarget.has_value());
|
||||
REQUIRE(*tr.currentTarget == player);
|
||||
}
|
||||
|
||||
TEST_CASE("BehaviorSystem: enemy ship with no target writes leftward movement intent",
|
||||
"[behavior]")
|
||||
{
|
||||
Fixture f;
|
||||
const EntityId enemyId = f.ships.spawn("interceptor", 1, QVector2D(100.0f, 0.0f),
|
||||
/*isEnemy=*/true);
|
||||
const entt::entity enemy = f.ships.spawn("interceptor", 1, QVector2D(100.0f, 0.0f),
|
||||
/*isEnemy=*/true);
|
||||
|
||||
f.ships.clearMovementIntents();
|
||||
f.ai.tickThreatResponse(f.ships, f.buildings);
|
||||
f.ai.tickThreatResponse(f.admin, f.buildings);
|
||||
|
||||
const Ship* enemy = f.ships.findShip(enemyId);
|
||||
REQUIRE(enemy->intent.priority == 3);
|
||||
REQUIRE(enemy->intent.target.x() < 0.0f); // moving leftward (toward asteroid)
|
||||
REQUIRE(intent(f.admin, enemy).priority == 3);
|
||||
REQUIRE(intent(f.admin, enemy).target.x() < 0.0f);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -303,72 +282,51 @@ TEST_CASE("BehaviorSystem: repair ship writes intent toward damaged friendly shi
|
||||
"[behavior]")
|
||||
{
|
||||
Fixture f;
|
||||
const EntityId repairId = f.ships.spawn("repair_ship", 1, QVector2D(0.0f, 0.0f));
|
||||
const EntityId friendlyId = f.ships.spawn("interceptor", 1, QVector2D(5.0f, 0.0f));
|
||||
const entt::entity repairShip = f.ships.spawn("repair_ship", 1, QVector2D(0.0f, 0.0f));
|
||||
const entt::entity friendly = f.ships.spawn("interceptor", 1, QVector2D(5.0f, 0.0f));
|
||||
|
||||
// Damage the friendly ship.
|
||||
f.ships.forEach([friendlyId](Ship& s) {
|
||||
if (s.id == friendlyId)
|
||||
{
|
||||
s.hp = s.maxHp * 0.5f;
|
||||
}
|
||||
});
|
||||
f.admin.get<Health>(friendly).hp = f.admin.get<Health>(friendly).maxHp * 0.5f;
|
||||
|
||||
f.ships.clearMovementIntents();
|
||||
f.ai.tickRepairBehavior(f.ships, f.buildings);
|
||||
f.ai.tickRepairBehavior(f.admin, f.buildings);
|
||||
|
||||
const Ship* repair = f.ships.findShip(repairId);
|
||||
REQUIRE(repair->intent.priority == 2);
|
||||
REQUIRE(repair->intent.target.x() == Approx(5.0f));
|
||||
REQUIRE(intent(f.admin, repairShip).priority == 2);
|
||||
REQUIRE(intent(f.admin, repairShip).target.x() == Approx(5.0f));
|
||||
}
|
||||
|
||||
TEST_CASE("BehaviorSystem: repair ship heals damaged ally within repair range",
|
||||
"[behavior]")
|
||||
{
|
||||
Fixture f;
|
||||
// Repair range = 80 tile units; place ships close together.
|
||||
const EntityId repairId = f.ships.spawn("repair_ship", 1, QVector2D(0.0f, 0.0f));
|
||||
const EntityId friendlyId = f.ships.spawn("interceptor", 1, QVector2D(1.0f, 0.0f));
|
||||
const entt::entity repairShip = f.ships.spawn("repair_ship", 1, QVector2D(0.0f, 0.0f));
|
||||
const entt::entity friendly = f.ships.spawn("interceptor", 1, QVector2D(1.0f, 0.0f));
|
||||
|
||||
const float initialHp = f.ships.findShip(friendlyId)->maxHp * 0.5f;
|
||||
f.ships.forEach([friendlyId, initialHp](Ship& s) {
|
||||
if (s.id == friendlyId)
|
||||
{
|
||||
s.hp = initialHp;
|
||||
}
|
||||
});
|
||||
const float initialHp = f.admin.get<Health>(friendly).maxHp * 0.5f;
|
||||
f.admin.get<Health>(friendly).hp = initialHp;
|
||||
|
||||
f.ships.clearMovementIntents();
|
||||
f.ai.tickRepairBehavior(f.ships, f.buildings);
|
||||
f.ai.tickRepairBehavior(f.admin, f.buildings);
|
||||
|
||||
// repair_rate_formula = "5 + x" at x=1 → 6; hp should have increased.
|
||||
const Ship* friendly = f.ships.findShip(friendlyId);
|
||||
REQUIRE(friendly->hp > initialHp);
|
||||
REQUIRE(health(f.admin, friendly).hp > initialHp);
|
||||
}
|
||||
|
||||
TEST_CASE("BehaviorSystem: repair ship does not heal above maxHp", "[behavior]")
|
||||
{
|
||||
Fixture f;
|
||||
const EntityId repairId = f.ships.spawn("repair_ship", 1, QVector2D(0.0f, 0.0f));
|
||||
const EntityId friendlyId = f.ships.spawn("interceptor", 1, QVector2D(1.0f, 0.0f));
|
||||
f.ships.spawn("repair_ship", 1, QVector2D(0.0f, 0.0f));
|
||||
const entt::entity friendly = f.ships.spawn("interceptor", 1, QVector2D(1.0f, 0.0f));
|
||||
|
||||
// Nearly full HP — one repair tick must not exceed maxHp.
|
||||
f.ships.forEach([friendlyId](Ship& s) {
|
||||
if (s.id == friendlyId)
|
||||
{
|
||||
s.hp = s.maxHp - 0.001f;
|
||||
}
|
||||
});
|
||||
f.admin.get<Health>(friendly).hp = f.admin.get<Health>(friendly).maxHp - 0.001f;
|
||||
|
||||
for (int i = 0; i < 5; ++i)
|
||||
{
|
||||
f.ships.clearMovementIntents();
|
||||
f.ai.tickRepairBehavior(f.ships, f.buildings);
|
||||
f.ai.tickRepairBehavior(f.admin, f.buildings);
|
||||
}
|
||||
|
||||
const Ship* friendly = f.ships.findShip(friendlyId);
|
||||
REQUIRE(friendly->hp <= friendly->maxHp);
|
||||
REQUIRE(friendly->hp == Approx(friendly->maxHp));
|
||||
const Health& h = health(f.admin, friendly);
|
||||
REQUIRE(h.hp <= h.maxHp);
|
||||
REQUIRE(h.hp == Approx(h.maxHp));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -378,51 +336,41 @@ TEST_CASE("BehaviorSystem: repair ship does not heal above maxHp", "[behavior]")
|
||||
TEST_CASE("BehaviorSystem: salvage ship writes intent toward nearest scrap", "[behavior]")
|
||||
{
|
||||
Fixture f;
|
||||
const EntityId shipId = f.ships.spawn("salvage_ship", 1, QVector2D(0.0f, 0.0f));
|
||||
const entt::entity ship = f.ships.spawn("salvage_ship", 1, QVector2D(0.0f, 0.0f));
|
||||
|
||||
// Scrap beyond collectionRange (50) but within sensorRange (250).
|
||||
const QVector2D scrapPos(100.0f, 0.0f);
|
||||
const Tick farFuture = 100000;
|
||||
f.scraps.spawn(scrapPos, 1, farFuture);
|
||||
f.scraps.spawn(scrapPos, 1, 100000);
|
||||
|
||||
f.ships.clearMovementIntents();
|
||||
f.ai.tickScrapCollector(f.ships, f.scraps, f.buildings);
|
||||
f.ai.tickScrapCollector(f.admin, f.scraps, f.buildings);
|
||||
|
||||
const Ship* s = f.ships.findShip(shipId);
|
||||
REQUIRE(s->intent.priority == 1);
|
||||
REQUIRE(s->intent.target.x() == Approx(scrapPos.x()));
|
||||
REQUIRE(intent(f.admin, ship).priority == 1);
|
||||
REQUIRE(intent(f.admin, ship).target.x() == Approx(scrapPos.x()));
|
||||
}
|
||||
|
||||
TEST_CASE("BehaviorSystem: salvage ship collects scrap on arrival", "[behavior]")
|
||||
{
|
||||
Fixture f;
|
||||
// Place scrap exactly at ship position so it is within collectionRange immediately.
|
||||
const EntityId shipId = f.ships.spawn("salvage_ship", 1, QVector2D(0.0f, 0.0f));
|
||||
const Tick farFuture = 100000;
|
||||
const EntityId scrapId = f.scraps.spawn(QVector2D(0.0f, 0.0f), 1, farFuture);
|
||||
const entt::entity ship = f.ships.spawn("salvage_ship", 1, QVector2D(0.0f, 0.0f));
|
||||
const entt::entity scrapEntity = f.scraps.spawn(QVector2D(0.0f, 0.0f), 1, 100000);
|
||||
|
||||
f.ships.clearMovementIntents();
|
||||
f.ai.tickScrapCollector(f.ships, f.scraps, f.buildings);
|
||||
f.ai.tickScrapCollector(f.admin, f.scraps, f.buildings);
|
||||
|
||||
const Ship* s = f.ships.findShip(shipId);
|
||||
REQUIRE(s->cargo->current == 1);
|
||||
REQUIRE(f.scraps.findScrap(scrapId) == nullptr); // consumed
|
||||
REQUIRE(f.admin.get<SalvageCargo>(ship).current == 1);
|
||||
REQUIRE_FALSE(f.admin.isValid(scrapEntity));
|
||||
}
|
||||
|
||||
TEST_CASE("BehaviorSystem: full-cargo salvage ship moves toward SalvageBay", "[behavior]")
|
||||
{
|
||||
Fixture f;
|
||||
|
||||
// Place a SalvageBay building so the ship has somewhere to deliver.
|
||||
// The SalvageBay occupies asteroid tiles (x < 0 convention); use negative coords.
|
||||
// We bypass construction time by ticking until it is operational.
|
||||
const EntityId bayId = f.buildings.place(BuildingType::SalvageBay,
|
||||
QPoint(-4, 0), Rotation::East, 0);
|
||||
Tick tick = 0;
|
||||
// SalvageBay construction_time_seconds = 15 → 450 ticks; run 500 to be safe.
|
||||
Tick t = 0;
|
||||
for (int i = 0; i < 500; ++i)
|
||||
{
|
||||
f.buildings.tickConstruction(tick++);
|
||||
f.buildings.tickConstruction(t++);
|
||||
if (f.buildings.findBuilding(bayId) != nullptr)
|
||||
{
|
||||
break;
|
||||
@@ -430,22 +378,16 @@ TEST_CASE("BehaviorSystem: full-cargo salvage ship moves toward SalvageBay", "[b
|
||||
}
|
||||
REQUIRE(f.buildings.findBuilding(bayId) != nullptr);
|
||||
|
||||
// Spawn salvage ship and fill its cargo.
|
||||
const EntityId shipId = f.ships.spawn("salvage_ship", 1, QVector2D(5.0f, 0.0f));
|
||||
f.ships.forEach([](Ship& s) {
|
||||
if (s.cargo)
|
||||
{
|
||||
s.cargo->current = s.cargo->capacity; // full cargo
|
||||
}
|
||||
});
|
||||
const entt::entity ship = f.ships.spawn("salvage_ship", 1, QVector2D(5.0f, 0.0f));
|
||||
SalvageCargo& cargo = f.admin.get<SalvageCargo>(ship);
|
||||
cargo.current = cargo.capacity; // full cargo
|
||||
|
||||
f.ships.clearMovementIntents();
|
||||
f.ai.tickScrapCollector(f.ships, f.scraps, f.buildings);
|
||||
f.ai.tickScrapCollector(f.admin, f.scraps, f.buildings);
|
||||
|
||||
// Intent should point toward the bay (x < 0 area), not rightward.
|
||||
const Ship* s = f.ships.findShip(shipId);
|
||||
REQUIRE(s->intent.priority == 1);
|
||||
REQUIRE(s->intent.target.x() < s->position.x());
|
||||
const MovementIntent& i = intent(f.admin, ship);
|
||||
REQUIRE(i.priority == 1);
|
||||
REQUIRE(i.target.x() < pos(f.admin, ship).value.x());
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -455,9 +397,8 @@ TEST_CASE("BehaviorSystem: full-cargo salvage ship moves toward SalvageBay", "[b
|
||||
TEST_CASE("SensorRange: sensorRange is populated from config formula at spawn", "[sensor]")
|
||||
{
|
||||
Fixture f;
|
||||
// interceptor sensor_range_formula = "200" (test config); verify at level 1.
|
||||
const EntityId id = f.ships.spawn("interceptor", 1, QVector2D(0.0f, 0.0f));
|
||||
REQUIRE(f.ships.findShip(id)->sensorRange == Approx(200.0f));
|
||||
const entt::entity e = f.ships.spawn("interceptor", 1, QVector2D(0.0f, 0.0f));
|
||||
REQUIRE(f.admin.get<SensorRange>(e).value == Approx(200.0f));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -467,45 +408,39 @@ TEST_CASE("SensorRange: sensorRange is populated from config formula at spawn",
|
||||
TEST_CASE("SensorRange: player combat ship acquires enemy just inside sensor range", "[sensor]")
|
||||
{
|
||||
Fixture f;
|
||||
// interceptor sensor_range = 200 (test config); enemy at 190 tiles.
|
||||
const EntityId playerId = f.ships.spawn("interceptor", 1, QVector2D(0.0f, 0.0f));
|
||||
const EntityId enemyId = f.ships.spawn("interceptor", 1, QVector2D(190.0f, 0.0f),
|
||||
/*isEnemy=*/true);
|
||||
const entt::entity player = f.ships.spawn("interceptor", 1, QVector2D(0.0f, 0.0f));
|
||||
const entt::entity enemy = f.ships.spawn("interceptor", 1, QVector2D(190.0f, 0.0f),
|
||||
/*isEnemy=*/true);
|
||||
|
||||
f.ships.clearMovementIntents();
|
||||
f.ai.tickThreatResponse(f.ships, f.buildings);
|
||||
f.ai.tickThreatResponse(f.admin, f.buildings);
|
||||
|
||||
const Ship* player = f.ships.findShip(playerId);
|
||||
REQUIRE(player->threatResponse->currentTarget == enemyId);
|
||||
REQUIRE(f.admin.get<ThreatResponse>(player).currentTarget == enemy);
|
||||
}
|
||||
|
||||
TEST_CASE("SensorRange: player combat ship ignores enemy just outside sensor range", "[sensor]")
|
||||
{
|
||||
Fixture f;
|
||||
// interceptor sensor_range = 200 (test config); enemy at 210 tiles.
|
||||
const EntityId playerId = f.ships.spawn("interceptor", 1, QVector2D(0.0f, 0.0f));
|
||||
const entt::entity player = f.ships.spawn("interceptor", 1, QVector2D(0.0f, 0.0f));
|
||||
f.ships.spawn("interceptor", 1, QVector2D(210.0f, 0.0f), /*isEnemy=*/true);
|
||||
|
||||
f.ships.clearMovementIntents();
|
||||
f.ai.tickThreatResponse(f.ships, f.buildings);
|
||||
f.ai.tickThreatResponse(f.admin, f.buildings);
|
||||
|
||||
const Ship* player = f.ships.findShip(playerId);
|
||||
REQUIRE_FALSE(player->threatResponse->currentTarget.has_value());
|
||||
REQUIRE_FALSE(f.admin.get<ThreatResponse>(player).currentTarget.has_value());
|
||||
}
|
||||
|
||||
TEST_CASE("SensorRange: enemy ship ignores player just outside sensor range", "[sensor]")
|
||||
{
|
||||
Fixture f;
|
||||
// interceptor sensor_range = 200 (test config); player at 210 tiles from enemy.
|
||||
f.ships.spawn("interceptor", 1, QVector2D(0.0f, 0.0f));
|
||||
const EntityId enemyId = f.ships.spawn("interceptor", 1, QVector2D(210.0f, 0.0f),
|
||||
/*isEnemy=*/true);
|
||||
const entt::entity enemy = f.ships.spawn("interceptor", 1, QVector2D(210.0f, 0.0f),
|
||||
/*isEnemy=*/true);
|
||||
|
||||
f.ships.clearMovementIntents();
|
||||
f.ai.tickThreatResponse(f.ships, f.buildings);
|
||||
f.ai.tickThreatResponse(f.admin, f.buildings);
|
||||
|
||||
const Ship* enemy = f.ships.findShip(enemyId);
|
||||
REQUIRE_FALSE(enemy->threatResponse->currentTarget.has_value());
|
||||
REQUIRE_FALSE(f.admin.get<ThreatResponse>(enemy).currentTarget.has_value());
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -515,47 +450,39 @@ TEST_CASE("SensorRange: enemy ship ignores player just outside sensor range", "[
|
||||
TEST_CASE("SensorRange: repair ship retreats from enemy within sensor range", "[sensor]")
|
||||
{
|
||||
Fixture f;
|
||||
// repair_ship sensor_range = 250; enemy at 200 tiles.
|
||||
const EntityId repairId = f.ships.spawn("repair_ship", 1, QVector2D(0.0f, 0.0f));
|
||||
const entt::entity repairShip = f.ships.spawn("repair_ship", 1, QVector2D(0.0f, 0.0f));
|
||||
f.ships.spawn("interceptor", 1, QVector2D(200.0f, 0.0f), /*isEnemy=*/true);
|
||||
|
||||
f.ships.clearMovementIntents();
|
||||
f.ai.tickRepairBehavior(f.ships, f.buildings);
|
||||
f.ai.tickRepairBehavior(f.admin, f.buildings);
|
||||
|
||||
const Ship* repair = f.ships.findShip(repairId);
|
||||
REQUIRE(repair->intent.priority == 2);
|
||||
REQUIRE(repair->intent.target.x() < 0.0f); // retreating leftward
|
||||
REQUIRE(intent(f.admin, repairShip).priority == 2);
|
||||
REQUIRE(intent(f.admin, repairShip).target.x() < 0.0f);
|
||||
}
|
||||
|
||||
TEST_CASE("SensorRange: repair ship does not retreat from enemy beyond sensor range", "[sensor]")
|
||||
{
|
||||
Fixture f;
|
||||
// repair_ship sensor_range = 250; enemy at 300 tiles.
|
||||
const EntityId repairId = f.ships.spawn("repair_ship", 1, QVector2D(0.0f, 0.0f));
|
||||
const entt::entity repairShip = f.ships.spawn("repair_ship", 1, QVector2D(0.0f, 0.0f));
|
||||
f.ships.spawn("interceptor", 1, QVector2D(300.0f, 0.0f), /*isEnemy=*/true);
|
||||
|
||||
f.ships.clearMovementIntents();
|
||||
f.ai.tickRepairBehavior(f.ships, f.buildings);
|
||||
f.ai.tickRepairBehavior(f.admin, f.buildings);
|
||||
|
||||
// Enemy outside sensor range → repair ship patrols rightward instead of retreating.
|
||||
const Ship* repair = f.ships.findShip(repairId);
|
||||
REQUIRE(repair->intent.target.x() > repair->position.x());
|
||||
REQUIRE(intent(f.admin, repairShip).target.x() > pos(f.admin, repairShip).value.x());
|
||||
}
|
||||
|
||||
TEST_CASE("SensorRange: repair ship does not acquire damaged ally beyond sensor range", "[sensor]")
|
||||
{
|
||||
Fixture f;
|
||||
// repair_ship sensor_range = 250; damaged friendly at 300 tiles.
|
||||
const EntityId repairId = f.ships.spawn("repair_ship", 1, QVector2D(0.0f, 0.0f));
|
||||
const EntityId friendlyId = f.ships.spawn("interceptor", 1, QVector2D(300.0f, 0.0f));
|
||||
f.ships.forEach([friendlyId](Ship& s) {
|
||||
if (s.id == friendlyId) { s.hp = s.maxHp * 0.5f; }
|
||||
});
|
||||
const entt::entity repairShip = f.ships.spawn("repair_ship", 1, QVector2D(0.0f, 0.0f));
|
||||
const entt::entity friendly = f.ships.spawn("interceptor", 1, QVector2D(300.0f, 0.0f));
|
||||
f.admin.get<Health>(friendly).hp = f.admin.get<Health>(friendly).maxHp * 0.5f;
|
||||
|
||||
f.ships.clearMovementIntents();
|
||||
f.ai.tickRepairBehavior(f.ships, f.buildings);
|
||||
f.ai.tickRepairBehavior(f.admin, f.buildings);
|
||||
|
||||
REQUIRE_FALSE(f.ships.findShip(repairId)->repairBehavior->currentTarget.has_value());
|
||||
REQUIRE_FALSE(f.admin.get<RepairBehavior>(repairShip).currentTarget.has_value());
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -565,14 +492,12 @@ TEST_CASE("SensorRange: repair ship does not acquire damaged ally beyond sensor
|
||||
TEST_CASE("SensorRange: salvage ship ignores scrap beyond sensor range", "[sensor]")
|
||||
{
|
||||
Fixture f;
|
||||
// salvage_ship sensor_range = 250; scrap at 300 tiles.
|
||||
const EntityId shipId = f.ships.spawn("salvage_ship", 1, QVector2D(0.0f, 0.0f));
|
||||
const entt::entity ship = f.ships.spawn("salvage_ship", 1, QVector2D(0.0f, 0.0f));
|
||||
f.scraps.spawn(QVector2D(300.0f, 0.0f), 1, 100000);
|
||||
|
||||
f.ships.clearMovementIntents();
|
||||
f.ai.tickScrapCollector(f.ships, f.scraps, f.buildings);
|
||||
f.ai.tickScrapCollector(f.admin, f.scraps, f.buildings);
|
||||
|
||||
const Ship* s = f.ships.findShip(shipId);
|
||||
REQUIRE(s->scrapCollector->scrapTarget == std::nullopt);
|
||||
REQUIRE(s->intent.target.x() > s->position.x()); // patrolling rightward
|
||||
REQUIRE_FALSE(f.admin.get<ScrapCollector>(ship).scrapTarget.has_value());
|
||||
REQUIRE(intent(f.admin, ship).target.x() > pos(f.admin, ship).value.x());
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user