switch to ECS architecture

This commit is contained in:
2026-05-22 20:31:39 +02:00
parent c18c4e4804
commit ca07cbaf0e
34 changed files with 1943 additions and 2074 deletions

View File

@@ -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());
}

View File

@@ -8,6 +8,8 @@
#include "BuildingType.h"
#include "CombatSystem.h"
#include "ConfigLoader.h"
#include "EcsComponents.h"
#include "EntityAdmin.h"
#include "FireEvent.h"
#include "ScrapSystem.h"
#include "Ship.h"
@@ -20,7 +22,6 @@ static GameConfig loadConfig()
return ConfigLoader::loadFromDirectory(CONFIG_DIR);
}
// Find the first ShipDef with a combat component.
static const ShipDef* findCombatShip(const GameConfig& cfg)
{
for (const ShipDef& def : cfg.ships.ships)
@@ -33,159 +34,104 @@ static const ShipDef* findCombatShip(const GameConfig& cfg)
return nullptr;
}
// Helper fixture for unit tests that need ships + combat but not a full Simulation.
struct CombatFixture
{
GameConfig cfg;
std::mt19937 rng;
EntityAdmin admin;
EntityId nextBldId;
BeltSystem belts;
ShipSystem ships;
BuildingSystem buildings;
CombatSystem combat;
explicit CombatFixture()
: cfg(loadConfig())
, rng(42)
, nextBldId(1)
, belts(cfg.world.beltSpeedTilesPerSecond)
, ships(cfg, admin)
, buildings(cfg, belts,
[this]() { return nextBldId++; },
[](int){},
[](const std::string&, QVector2D, const std::optional<ShipLayoutConfig>&) {},
rng)
, combat(cfg)
{
}
void wireEnemyTarget(entt::entity enemy, entt::entity playerTarget)
{
if (admin.hasAll<Weapon>(enemy))
{
admin.get<Weapon>(enemy).currentTarget = playerTarget;
admin.get<Weapon>(enemy).cooldownTicks = 0.0f;
}
if (admin.hasAll<ThreatResponse>(enemy))
{
admin.get<ThreatResponse>(enemy).currentTarget = playerTarget;
}
}
};
// ---------------------------------------------------------------------------
// Ship weapon firing
// ---------------------------------------------------------------------------
TEST_CASE("CombatSystem: ship fires when cooldown=0 and target in range", "[combat]")
{
const GameConfig cfg = loadConfig();
std::mt19937 rng(42);
const ShipDef* combatDef = findCombatShip(cfg);
CombatFixture f;
const ShipDef* combatDef = findCombatShip(f.cfg);
REQUIRE(combatDef != nullptr);
BeltSystem belts(cfg.world.beltSpeedTilesPerSecond);
EntityId nextShipId = 1;
EntityId nextBldId = 100;
ShipSystem ships(cfg, [&nextShipId]() { return nextShipId++; });
BuildingSystem buildings(cfg, belts,
[&nextBldId]() { return nextBldId++; },
[](int){},
[](const std::string&, QVector2D, const std::optional<ShipLayoutConfig>&) {},
rng);
const entt::entity enemy = f.ships.spawn(combatDef->id, 1, QVector2D(5.0f, 5.0f), true);
const entt::entity player = f.ships.spawn(combatDef->id, 1, QVector2D(4.0f, 5.0f), false);
f.wireEnemyTarget(enemy, player);
// Spawn an enemy combat ship close to the player side.
const EntityId enemyId = ships.spawn(combatDef->id, 1,
QVector2D(5.0f, 5.0f), /*isEnemy=*/true);
const float hpBefore = f.admin.get<Health>(player).hp;
// Spawn a player combat ship in front of the enemy.
const EntityId playerId = ships.spawn(combatDef->id, 1,
QVector2D(4.0f, 5.0f), /*isEnemy=*/false);
// Wire the enemy's weapon target manually.
ships.forEach([&](Ship& s)
{
if (s.id == enemyId && s.weapon)
{
s.weapon->currentTarget = playerId;
s.weapon->cooldownTicks = 0.0f;
if (s.threatResponse)
{
s.threatResponse->currentTarget = playerId;
}
}
});
// Record player HP before combat.
float hpBefore = 0.0f;
for (const Ship& s : ships.allShips())
{
if (s.id == playerId) { hpBefore = s.hp; }
}
CombatSystem combat(cfg);
std::vector<FireEvent> events;
combat.tick(0, ships, buildings, events);
combat.applyPendingDamage(5, ships, buildings);
f.combat.tick(0, f.admin, f.buildings, events);
f.combat.applyPendingDamage(5, f.admin);
float hpAfter = 0.0f;
for (const Ship& s : ships.allShips())
{
if (s.id == playerId) { hpAfter = s.hp; }
}
REQUIRE(hpAfter < hpBefore);
REQUIRE(f.admin.get<Health>(player).hp < hpBefore);
REQUIRE(events.size() >= 1);
}
TEST_CASE("CombatSystem: cooldown prevents firing before it expires", "[combat]")
{
const GameConfig cfg = loadConfig();
std::mt19937 rng(42);
const ShipDef* combatDef = findCombatShip(cfg);
CombatFixture f;
const ShipDef* combatDef = findCombatShip(f.cfg);
REQUIRE(combatDef != nullptr);
BeltSystem belts(cfg.world.beltSpeedTilesPerSecond);
EntityId nextShipId = 1;
EntityId nextBldId = 100;
ShipSystem ships(cfg, [&nextShipId]() { return nextShipId++; });
BuildingSystem buildings(cfg, belts,
[&nextBldId]() { return nextBldId++; },
[](int){},
[](const std::string&, QVector2D, const std::optional<ShipLayoutConfig>&) {},
rng);
const entt::entity enemy = f.ships.spawn(combatDef->id, 1, QVector2D(5.0f, 5.0f), true);
const entt::entity player = f.ships.spawn(combatDef->id, 1, QVector2D(4.0f, 5.0f), false);
const EntityId enemyId = ships.spawn(combatDef->id, 1, QVector2D(5.0f, 5.0f), true);
const EntityId playerId = ships.spawn(combatDef->id, 1, QVector2D(4.0f, 5.0f), false);
f.wireEnemyTarget(enemy, player);
f.admin.get<Weapon>(enemy).cooldownTicks = 3.0f; // override to 3
// Set cooldown to 3 so it won't fire on tick 0 or 1 or 2.
ships.forEach([&](Ship& s)
{
if (s.id == enemyId && s.weapon)
{
s.weapon->currentTarget = playerId;
s.weapon->cooldownTicks = 3.0f;
if (s.threatResponse)
{
s.threatResponse->currentTarget = playerId;
}
}
});
CombatSystem combat(cfg);
std::vector<FireEvent> events;
// Ticks 0 and 1: cooldown still > 0 after decrement → no fire.
combat.tick(0, ships, buildings, events);
combat.tick(1, ships, buildings, events);
f.combat.tick(0, f.admin, f.buildings, events);
f.combat.tick(1, f.admin, f.buildings, events);
REQUIRE(events.empty());
// Tick 2: cooldown reaches 0 → fires.
combat.tick(2, ships, buildings, events);
f.combat.tick(2, f.admin, f.buildings, events);
REQUIRE(events.size() == 1);
}
TEST_CASE("CombatSystem: no fire when target is out of range", "[combat]")
{
const GameConfig cfg = loadConfig();
std::mt19937 rng(42);
const ShipDef* combatDef = findCombatShip(cfg);
CombatFixture f;
const ShipDef* combatDef = findCombatShip(f.cfg);
REQUIRE(combatDef != nullptr);
BeltSystem belts(cfg.world.beltSpeedTilesPerSecond);
EntityId nextShipId = 1;
EntityId nextBldId = 100;
ShipSystem ships(cfg, [&nextShipId]() { return nextShipId++; });
BuildingSystem buildings(cfg, belts,
[&nextBldId]() { return nextBldId++; },
[](int){},
[](const std::string&, QVector2D, const std::optional<ShipLayoutConfig>&) {},
rng);
const entt::entity enemy = f.ships.spawn(combatDef->id, 1, QVector2D(0.0f, 0.0f), true);
const entt::entity player = f.ships.spawn(combatDef->id, 1, QVector2D(500.0f, 0.0f), false);
f.wireEnemyTarget(enemy, player);
const EntityId enemyId = ships.spawn(combatDef->id, 1, QVector2D(0.0f, 0.0f), true);
const EntityId playerId = ships.spawn(combatDef->id, 1, QVector2D(500.0f, 0.0f), false);
ships.forEach([&](Ship& s)
{
if (s.id == enemyId && s.weapon)
{
s.weapon->currentTarget = playerId;
s.weapon->cooldownTicks = 0.0f;
if (s.threatResponse)
{
s.threatResponse->currentTarget = playerId;
}
}
});
CombatSystem combat(cfg);
std::vector<FireEvent> events;
combat.tick(0, ships, buildings, events);
f.combat.tick(0, f.admin, f.buildings, events);
REQUIRE(events.empty());
}
@@ -197,49 +143,37 @@ TEST_CASE("CombatSystem: player station fires at enemy ship in range", "[combat]
{
Simulation sim(loadConfig(), 42);
// Find the player defence station.
EntityId stationId = kInvalidEntityId;
for (const Building& b : sim.buildings().allBuildings())
{
if (b.type == BuildingType::PlayerDefenceStation)
{
stationId = b.id;
break;
}
}
REQUIRE(stationId != kInvalidEntityId);
// Place an enemy ship close to the player station.
// Find the player station entity via ECS.
entt::entity stationEntity = entt::null;
QVector2D stationCenter;
for (const Building& b : sim.buildings().allBuildings())
{
if (b.id == stationId)
sim.admin().forEach<StationBody, Faction>(
[&](entt::entity e, const StationBody& sb, const Faction& f)
{
stationCenter = QVector2D(
b.anchor.x() + b.footprint.width() / 2.0f,
b.anchor.y() + b.footprint.height() / 2.0f);
break;
}
}
if (!f.isEnemy && stationEntity == entt::null)
{
stationEntity = e;
stationCenter = QVector2D(
sb.anchor.x() + sb.footprint.width() / 2.0f,
sb.anchor.y() + sb.footprint.height() / 2.0f);
}
});
REQUIRE(sim.admin().isValid(stationEntity));
// Find a combat ship schematic for the enemy.
const ShipDef* combatDef = findCombatShip(sim.config());
REQUIRE(combatDef != nullptr);
const EntityId enemyId = sim.ships().spawn(
const entt::entity enemyShip = sim.ships().spawn(
combatDef->id, 1,
QVector2D(stationCenter.x() + 1.0f, stationCenter.y()),
/*isEnemy=*/true);
// Tick to let station auto-acquire and fire.
sim.tick();
// Check that a fire event was emitted with stationId as shooter.
const std::vector<FireEvent> events = sim.drainFireEvents();
bool stationFired = false;
for (const FireEvent& e : events)
for (const FireEvent& evt : events)
{
if (e.shooter == stationId) { stationFired = true; }
if (evt.shooter == stationEntity) { stationFired = true; }
}
REQUIRE(stationFired);
}
@@ -248,26 +182,24 @@ TEST_CASE("CombatSystem: enemy station fires at player ship in range", "[combat]
{
Simulation sim(loadConfig(), 42);
// Find the enemy defence station.
EntityId stationId = kInvalidEntityId;
entt::entity stationEntity = entt::null;
QVector2D stationCenter;
for (const Building& b : sim.buildings().allBuildings())
{
if (b.type == BuildingType::EnemyDefenceStation)
sim.admin().forEach<StationBody, Faction>(
[&](entt::entity e, const StationBody& sb, const Faction& f)
{
stationId = b.id;
stationCenter = QVector2D(
b.anchor.x() + b.footprint.width() / 2.0f,
b.anchor.y() + b.footprint.height() / 2.0f);
break;
}
}
REQUIRE(stationId != kInvalidEntityId);
if (f.isEnemy && stationEntity == entt::null)
{
stationEntity = e;
stationCenter = QVector2D(
sb.anchor.x() + sb.footprint.width() / 2.0f,
sb.anchor.y() + sb.footprint.height() / 2.0f);
}
});
REQUIRE(sim.admin().isValid(stationEntity));
const ShipDef* combatDef = findCombatShip(sim.config());
REQUIRE(combatDef != nullptr);
// Spawn a player ship right next to the enemy station.
sim.ships().spawn(
combatDef->id, 1,
QVector2D(stationCenter.x() - 1.0f, stationCenter.y()),
@@ -277,9 +209,9 @@ TEST_CASE("CombatSystem: enemy station fires at player ship in range", "[combat]
const std::vector<FireEvent> events = sim.drainFireEvents();
bool stationFired = false;
for (const FireEvent& e : events)
for (const FireEvent& evt : events)
{
if (e.shooter == stationId) { stationFired = true; }
if (evt.shooter == stationEntity) { stationFired = true; }
}
REQUIRE(stationFired);
}
@@ -288,25 +220,25 @@ TEST_CASE("CombatSystem: player ship fires at enemy station in range", "[combat]
{
Simulation sim(loadConfig(), 42);
EntityId stationId = kInvalidEntityId;
entt::entity stationEntity = entt::null;
QVector2D stationCenter;
for (const Building& b : sim.buildings().allBuildings())
{
if (b.type == BuildingType::EnemyDefenceStation)
sim.admin().forEach<StationBody, Faction>(
[&](entt::entity e, const StationBody& sb, const Faction& f)
{
stationId = b.id;
stationCenter = QVector2D(
b.anchor.x() + b.footprint.width() / 2.0f,
b.anchor.y() + b.footprint.height() / 2.0f);
break;
}
}
REQUIRE(stationId != kInvalidEntityId);
if (f.isEnemy && stationEntity == entt::null)
{
stationEntity = e;
stationCenter = QVector2D(
sb.anchor.x() + sb.footprint.width() / 2.0f,
sb.anchor.y() + sb.footprint.height() / 2.0f);
}
});
REQUIRE(sim.admin().isValid(stationEntity));
const ShipDef* combatDef = findCombatShip(sim.config());
REQUIRE(combatDef != nullptr);
const EntityId playerId = sim.ships().spawn(
const entt::entity playerShip = sim.ships().spawn(
combatDef->id, 1,
QVector2D(stationCenter.x() - 1.0f, stationCenter.y()),
/*isEnemy=*/false);
@@ -315,9 +247,9 @@ TEST_CASE("CombatSystem: player ship fires at enemy station in range", "[combat]
const std::vector<FireEvent> events = sim.drainFireEvents();
bool playerFiredAtStation = false;
for (const FireEvent& e : events)
for (const FireEvent& evt : events)
{
if (e.shooter == playerId && e.target == stationId)
if (evt.shooter == playerShip && evt.target == stationEntity)
{
playerFiredAtStation = true;
}
@@ -331,219 +263,86 @@ TEST_CASE("CombatSystem: player ship fires at enemy station in range", "[combat]
TEST_CASE("CombatSystem: damage not applied before impact tick", "[combat]")
{
const GameConfig cfg = loadConfig();
std::mt19937 rng(42);
const ShipDef* combatDef = findCombatShip(cfg);
CombatFixture f;
const ShipDef* combatDef = findCombatShip(f.cfg);
REQUIRE(combatDef != nullptr);
BeltSystem belts(cfg.world.beltSpeedTilesPerSecond);
EntityId nextShipId = 1;
EntityId nextBldId = 100;
ShipSystem ships(cfg, [&nextShipId]() { return nextShipId++; });
BuildingSystem buildings(cfg, belts,
[&nextBldId]() { return nextBldId++; },
[](int){},
[](const std::string&, QVector2D, const std::optional<ShipLayoutConfig>&) {},
rng);
const entt::entity enemy = f.ships.spawn(combatDef->id, 1, QVector2D(5.0f, 5.0f), true);
const entt::entity player = f.ships.spawn(combatDef->id, 1, QVector2D(4.0f, 5.0f), false);
f.wireEnemyTarget(enemy, player);
const EntityId enemyId = ships.spawn(combatDef->id, 1, QVector2D(5.0f, 5.0f), true);
const EntityId playerId = ships.spawn(combatDef->id, 1, QVector2D(4.0f, 5.0f), false);
const float hpBefore = f.admin.get<Health>(player).hp;
ships.forEach([&](Ship& s)
{
if (s.id == enemyId && s.weapon)
{
s.weapon->currentTarget = playerId;
s.weapon->cooldownTicks = 0.0f;
if (s.threatResponse)
{
s.threatResponse->currentTarget = playerId;
}
}
});
float hpBefore = 0.0f;
for (const Ship& s : ships.allShips())
{
if (s.id == playerId) { hpBefore = s.hp; }
}
CombatSystem combat(cfg);
std::vector<FireEvent> events;
combat.tick(0, ships, buildings, events);
f.combat.tick(0, f.admin, f.buildings, events);
// Ticks 1-4: damage must not have arrived yet.
for (Tick t = 1; t < 5; ++t)
{
combat.applyPendingDamage(t, ships, buildings);
float hp = 0.0f;
for (const Ship& s : ships.allShips())
{
if (s.id == playerId) { hp = s.hp; }
}
REQUIRE(hp == Approx(hpBefore));
f.combat.applyPendingDamage(t, f.admin);
REQUIRE(f.admin.get<Health>(player).hp == Approx(hpBefore));
}
}
TEST_CASE("CombatSystem: damage applied exactly at impact tick", "[combat]")
{
const GameConfig cfg = loadConfig();
std::mt19937 rng(42);
const ShipDef* combatDef = findCombatShip(cfg);
CombatFixture f;
const ShipDef* combatDef = findCombatShip(f.cfg);
REQUIRE(combatDef != nullptr);
BeltSystem belts(cfg.world.beltSpeedTilesPerSecond);
EntityId nextShipId = 1;
EntityId nextBldId = 100;
ShipSystem ships(cfg, [&nextShipId]() { return nextShipId++; });
BuildingSystem buildings(cfg, belts,
[&nextBldId]() { return nextBldId++; },
[](int){},
[](const std::string&, QVector2D, const std::optional<ShipLayoutConfig>&) {},
rng);
const entt::entity enemy = f.ships.spawn(combatDef->id, 1, QVector2D(5.0f, 5.0f), true);
const entt::entity player = f.ships.spawn(combatDef->id, 1, QVector2D(4.0f, 5.0f), false);
f.wireEnemyTarget(enemy, player);
const EntityId enemyId = ships.spawn(combatDef->id, 1, QVector2D(5.0f, 5.0f), true);
const EntityId playerId = ships.spawn(combatDef->id, 1, QVector2D(4.0f, 5.0f), false);
const float hpBefore = f.admin.get<Health>(player).hp;
ships.forEach([&](Ship& s)
{
if (s.id == enemyId && s.weapon)
{
s.weapon->currentTarget = playerId;
s.weapon->cooldownTicks = 0.0f;
if (s.threatResponse)
{
s.threatResponse->currentTarget = playerId;
}
}
});
float hpBefore = 0.0f;
for (const Ship& s : ships.allShips())
{
if (s.id == playerId) { hpBefore = s.hp; }
}
CombatSystem combat(cfg);
std::vector<FireEvent> events;
combat.tick(0, ships, buildings, events);
combat.applyPendingDamage(5, ships, buildings);
f.combat.tick(0, f.admin, f.buildings, events);
f.combat.applyPendingDamage(5, f.admin);
float hpAfter = 0.0f;
for (const Ship& s : ships.allShips())
{
if (s.id == playerId) { hpAfter = s.hp; }
}
REQUIRE(hpAfter < hpBefore);
REQUIRE(f.admin.get<Health>(player).hp < hpBefore);
}
TEST_CASE("CombatSystem: damage silently dropped if target already dead", "[combat]")
{
const GameConfig cfg = loadConfig();
std::mt19937 rng(42);
const ShipDef* combatDef = findCombatShip(cfg);
CombatFixture f;
const ShipDef* combatDef = findCombatShip(f.cfg);
REQUIRE(combatDef != nullptr);
BeltSystem belts(cfg.world.beltSpeedTilesPerSecond);
EntityId nextShipId = 1;
EntityId nextBldId = 100;
ShipSystem ships(cfg, [&nextShipId]() { return nextShipId++; });
BuildingSystem buildings(cfg, belts,
[&nextBldId]() { return nextBldId++; },
[](int){},
[](const std::string&, QVector2D, const std::optional<ShipLayoutConfig>&) {},
rng);
const entt::entity enemy = f.ships.spawn(combatDef->id, 1, QVector2D(5.0f, 5.0f), true);
const entt::entity player = f.ships.spawn(combatDef->id, 1, QVector2D(4.0f, 5.0f), false);
f.wireEnemyTarget(enemy, player);
const EntityId enemyId = ships.spawn(combatDef->id, 1, QVector2D(5.0f, 5.0f), true);
const EntityId playerId = ships.spawn(combatDef->id, 1, QVector2D(4.0f, 5.0f), false);
ships.forEach([&](Ship& s)
{
if (s.id == enemyId && s.weapon)
{
s.weapon->currentTarget = playerId;
s.weapon->cooldownTicks = 0.0f;
if (s.threatResponse)
{
s.threatResponse->currentTarget = playerId;
}
}
});
CombatSystem combat(cfg);
std::vector<FireEvent> events;
combat.tick(0, ships, buildings, events);
f.combat.tick(0, f.admin, f.buildings, events);
// Target is removed before impact.
ships.despawn(playerId);
f.ships.despawn(player);
// Should not crash; damage is silently dropped.
combat.applyPendingDamage(5, ships, buildings);
// Should not crash.
f.combat.applyPendingDamage(5, f.admin);
REQUIRE(ships.findShip(playerId) == nullptr);
REQUIRE_FALSE(f.admin.isValid(player));
}
TEST_CASE("CombatSystem: damage still applied if shooter already dead", "[combat]")
{
const GameConfig cfg = loadConfig();
std::mt19937 rng(42);
const ShipDef* combatDef = findCombatShip(cfg);
CombatFixture f;
const ShipDef* combatDef = findCombatShip(f.cfg);
REQUIRE(combatDef != nullptr);
BeltSystem belts(cfg.world.beltSpeedTilesPerSecond);
EntityId nextShipId = 1;
EntityId nextBldId = 100;
ShipSystem ships(cfg, [&nextShipId]() { return nextShipId++; });
BuildingSystem buildings(cfg, belts,
[&nextBldId]() { return nextBldId++; },
[](int){},
[](const std::string&, QVector2D, const std::optional<ShipLayoutConfig>&) {},
rng);
const entt::entity enemy = f.ships.spawn(combatDef->id, 1, QVector2D(5.0f, 5.0f), true);
const entt::entity player = f.ships.spawn(combatDef->id, 1, QVector2D(4.0f, 5.0f), false);
f.wireEnemyTarget(enemy, player);
const EntityId enemyId = ships.spawn(combatDef->id, 1, QVector2D(5.0f, 5.0f), true);
const EntityId playerId = ships.spawn(combatDef->id, 1, QVector2D(4.0f, 5.0f), false);
const float hpBefore = f.admin.get<Health>(player).hp;
ships.forEach([&](Ship& s)
{
if (s.id == enemyId && s.weapon)
{
s.weapon->currentTarget = playerId;
s.weapon->cooldownTicks = 0.0f;
if (s.threatResponse)
{
s.threatResponse->currentTarget = playerId;
}
}
});
float hpBefore = 0.0f;
for (const Ship& s : ships.allShips())
{
if (s.id == playerId) { hpBefore = s.hp; }
}
CombatSystem combat(cfg);
std::vector<FireEvent> events;
combat.tick(0, ships, buildings, events);
f.combat.tick(0, f.admin, f.buildings, events);
// Shooter is removed before impact.
ships.despawn(enemyId);
f.ships.despawn(enemy);
// Damage must still land on the target.
combat.applyPendingDamage(5, ships, buildings);
f.combat.applyPendingDamage(5, f.admin);
float hpAfter = 0.0f;
for (const Ship& s : ships.allShips())
{
if (s.id == playerId) { hpAfter = s.hp; }
}
REQUIRE(hpAfter < hpBefore);
REQUIRE(f.admin.get<Health>(player).hp < hpBefore);
}
// ---------------------------------------------------------------------------
@@ -557,22 +356,20 @@ TEST_CASE("CombatSystem: dead ship is removed after tick step 9", "[combat]")
const ShipDef* combatDef = findCombatShip(sim.config());
REQUIRE(combatDef != nullptr);
const EntityId shipId = sim.ships().spawn(combatDef->id, 1,
const entt::entity ship = sim.ships().spawn(combatDef->id, 1,
QVector2D(10.0f, 10.0f));
// Set hp to lethal.
sim.ships().damageShip(shipId, 9999.0f);
sim.admin().get<Health>(ship).hp = -1.0f;
sim.tick();
REQUIRE(sim.ships().findShip(shipId) == nullptr);
REQUIRE_FALSE(sim.admin().isValid(ship));
}
TEST_CASE("CombatSystem: scrap is spawned on ship death", "[combat]")
{
Simulation sim(loadConfig(), 42);
// Find a ship def that drops scrap.
const ShipDef* droppingDef = nullptr;
for (const ShipDef& def : sim.config().ships.ships)
{
@@ -584,27 +381,25 @@ TEST_CASE("CombatSystem: scrap is spawned on ship death", "[combat]")
}
REQUIRE(droppingDef != nullptr);
const EntityId shipId = sim.ships().spawn(droppingDef->id, 1,
const entt::entity ship = sim.ships().spawn(droppingDef->id, 1,
QVector2D(10.0f, 10.0f));
sim.ships().damageShip(shipId, 9999.0f);
sim.admin().get<Health>(ship).hp = -1.0f;
sim.tick();
// At least one scrap entity should now exist.
REQUIRE(!sim.scraps().allScraps().empty());
REQUIRE(!sim.scraps().allScrapInfo().empty());
}
TEST_CASE("CombatSystem: HQ death sets game over", "[combat]")
{
Simulation sim(loadConfig(), 42);
sim.buildings().forEachBuilding([](Building& b)
{
if (b.type == BuildingType::Hq)
// Damage the HQ proxy entity (has HqProxy + Health).
sim.admin().forEach<HqProxy, Health>(
[](entt::entity /*e*/, const HqProxy& /*hq*/, Health& h)
{
b.hp = -1.0f;
}
});
h.hp = -1.0f;
});
sim.tick();

View File

@@ -2,25 +2,23 @@
#include <QVector2D>
#include "EntityId.h"
#include "Scrap.h"
#include "EntityAdmin.h"
#include "ScrapSystem.h"
// ---------------------------------------------------------------------------
// Spawn
// ---------------------------------------------------------------------------
TEST_CASE("ScrapSystem: spawn returns a findable scrap with correct fields", "[scrap]")
TEST_CASE("ScrapSystem: spawn returns a valid entity with correct scrap data", "[scrap]")
{
EntityId nextId = 1;
ScrapSystem ss([&nextId]() { return nextId++; });
EntityAdmin admin;
ScrapSystem ss(admin);
const EntityId id = ss.spawn(QVector2D(3.0f, 4.0f), 5, 100);
const Scrap* s = ss.findScrap(id);
const entt::entity e = ss.spawn(QVector2D(3.0f, 4.0f), 5, 100);
REQUIRE(s != nullptr);
REQUIRE(s->amount == 5);
REQUIRE(s->despawnAt == 100);
REQUIRE(admin.isValid(e));
REQUIRE(admin.get<ScrapData>(e).amount == 5);
REQUIRE(admin.get<DespawnAt>(e).tick == 100);
}
// ---------------------------------------------------------------------------
@@ -29,24 +27,24 @@ TEST_CASE("ScrapSystem: spawn returns a findable scrap with correct fields", "[s
TEST_CASE("ScrapSystem: scrap still present one tick before despawnAt", "[scrap]")
{
EntityId nextId = 1;
ScrapSystem ss([&nextId]() { return nextId++; });
EntityAdmin admin;
ScrapSystem ss(admin);
const EntityId id = ss.spawn(QVector2D(0.0f, 0.0f), 1, 50);
const entt::entity e = ss.spawn(QVector2D(0.0f, 0.0f), 1, 50);
ss.tickDespawn(49);
REQUIRE(ss.findScrap(id) != nullptr);
REQUIRE(admin.isValid(e));
}
TEST_CASE("ScrapSystem: scrap removed at despawnAt tick", "[scrap]")
{
EntityId nextId = 1;
ScrapSystem ss([&nextId]() { return nextId++; });
EntityAdmin admin;
ScrapSystem ss(admin);
const EntityId id = ss.spawn(QVector2D(0.0f, 0.0f), 1, 50);
const entt::entity e = ss.spawn(QVector2D(0.0f, 0.0f), 1, 50);
ss.tickDespawn(50);
REQUIRE(ss.findScrap(id) == nullptr);
REQUIRE_FALSE(admin.isValid(e));
}
// ---------------------------------------------------------------------------
@@ -55,29 +53,56 @@ TEST_CASE("ScrapSystem: scrap removed at despawnAt tick", "[scrap]")
TEST_CASE("ScrapSystem: tickDespawn removes only expired scraps", "[scrap]")
{
EntityId nextId = 1;
ScrapSystem ss([&nextId]() { return nextId++; });
EntityAdmin admin;
ScrapSystem ss(admin);
const EntityId earlyId = ss.spawn(QVector2D(0.0f, 0.0f), 1, 30);
const EntityId lateId = ss.spawn(QVector2D(1.0f, 0.0f), 2, 60);
const entt::entity earlyE = ss.spawn(QVector2D(0.0f, 0.0f), 1, 30);
const entt::entity lateE = ss.spawn(QVector2D(1.0f, 0.0f), 2, 60);
ss.tickDespawn(30);
REQUIRE(ss.findScrap(earlyId) == nullptr);
REQUIRE(ss.findScrap(lateId) != nullptr);
REQUIRE_FALSE(admin.isValid(earlyE));
REQUIRE(admin.isValid(lateE));
}
// ---------------------------------------------------------------------------
// Entity ids
// Consume
// ---------------------------------------------------------------------------
TEST_CASE("ScrapSystem: spawned scraps receive strictly increasing entity ids", "[scrap]")
TEST_CASE("ScrapSystem: consume returns amount and destroys entity", "[scrap]")
{
EntityId nextId = 1;
ScrapSystem ss([&nextId]() { return nextId++; });
EntityAdmin admin;
ScrapSystem ss(admin);
const EntityId id1 = ss.spawn(QVector2D(0.0f, 0.0f), 1, 100);
const EntityId id2 = ss.spawn(QVector2D(1.0f, 0.0f), 2, 200);
const entt::entity e = ss.spawn(QVector2D(0.0f, 0.0f), 7, 100);
REQUIRE(id2 > id1);
const std::optional<int> amount = ss.consume(e);
REQUIRE(amount.has_value());
REQUIRE(*amount == 7);
REQUIRE_FALSE(admin.isValid(e));
}
TEST_CASE("ScrapSystem: consume returns nullopt for invalid entity", "[scrap]")
{
EntityAdmin admin;
ScrapSystem ss(admin);
const std::optional<int> amount = ss.consume(entt::null);
REQUIRE_FALSE(amount.has_value());
}
// ---------------------------------------------------------------------------
// allScrapInfo
// ---------------------------------------------------------------------------
TEST_CASE("ScrapSystem: allScrapInfo returns all spawned scrap", "[scrap]")
{
EntityAdmin admin;
ScrapSystem ss(admin);
ss.spawn(QVector2D(1.0f, 2.0f), 3, 100);
ss.spawn(QVector2D(4.0f, 5.0f), 6, 200);
const std::vector<ScrapInfo> info = ss.allScrapInfo();
REQUIRE(info.size() == 2);
}

View File

@@ -4,6 +4,8 @@
#include "BuildingSystem.h"
#include "BuildingType.h"
#include "ConfigLoader.h"
#include "EcsComponents.h"
#include "EntityAdmin.h"
#include "GameConfig.h"
#include "ItemType.h"
#include "ModulesConfig.h"
@@ -96,13 +98,12 @@ TEST_CASE("Ship spawn: no modules leaves base stats unchanged", "[modules]")
const double x = static_cast<double>(def->schematic.playerProductionLevel);
const float expectedHp = static_cast<float>(def->health.hpFormula.evaluate(x));
const EntityId id = sim.ships().spawn("interceptor",
const entt::entity e = sim.ships().spawn("interceptor",
def->schematic.playerProductionLevel,
QVector2D(5.0f, 5.0f), false, std::nullopt);
const Ship* ship = sim.ships().findShip(id);
REQUIRE(ship != nullptr);
CHECK(ship->maxHp == Approx(expectedHp));
REQUIRE(sim.admin().isValid(e));
CHECK(sim.admin().get<Health>(e).maxHp == Approx(expectedHp));
}
TEST_CASE("Ship spawn: multiplicative HP module applies correctly", "[modules]")
@@ -121,16 +122,15 @@ TEST_CASE("Ship spawn: multiplicative HP module applies correctly", "[modules]")
pm.rotation = Rotation::East;
layout.placedModules.push_back(pm);
const EntityId id = sim.ships().spawn("interceptor",
const entt::entity e = sim.ships().spawn("interceptor",
def->schematic.playerProductionLevel,
QVector2D(5.0f, 5.0f), false, layout);
const Ship* ship = sim.ships().findShip(id);
REQUIRE(ship != nullptr);
REQUIRE(sim.admin().isValid(e));
// armor_plate has multiplied_hp_formula = "1.5"
// final = base * (1 + (1.5 - 1)) + 0 = base * 1.5
CHECK(ship->maxHp == Approx(baseHp * 1.5f));
CHECK(ship->hp == ship->maxHp);
CHECK(sim.admin().get<Health>(e).maxHp == Approx(baseHp * 1.5f));
CHECK(sim.admin().get<Health>(e).hp == sim.admin().get<Health>(e).maxHp);
}
TEST_CASE("Ship spawn: additive sensor module applies correctly", "[modules]")
@@ -149,15 +149,14 @@ TEST_CASE("Ship spawn: additive sensor module applies correctly", "[modules]")
pm.rotation = Rotation::East;
layout.placedModules.push_back(pm);
const EntityId id = sim.ships().spawn("interceptor",
const entt::entity e = sim.ships().spawn("interceptor",
def->schematic.playerProductionLevel,
QVector2D(5.0f, 5.0f), false, layout);
const Ship* ship = sim.ships().findShip(id);
REQUIRE(ship != nullptr);
REQUIRE(sim.admin().isValid(e));
// sensor_booster has added_sensor_range_formula = "10"
// final = base * 1.0 + 10 = base + 10
CHECK(ship->sensorRange == Approx(baseRange + 10.0f));
CHECK(sim.admin().get<SensorRange>(e).value == Approx(baseRange + 10.0f));
}
TEST_CASE("Ship spawn: multiple modules stack correctly", "[modules]")
@@ -179,16 +178,15 @@ TEST_CASE("Ship spawn: multiple modules stack correctly", "[modules]")
layout.placedModules.push_back(pm);
}
const EntityId id = sim.ships().spawn("interceptor",
const entt::entity e = sim.ships().spawn("interceptor",
def->schematic.playerProductionLevel,
QVector2D(5.0f, 5.0f), false, layout);
const Ship* ship = sim.ships().findShip(id);
REQUIRE(ship != nullptr);
REQUIRE(sim.admin().isValid(e));
// Two armor_plates: each 1.5 multiplier
// total_mult = 1 + (1.5 - 1) + (1.5 - 1) = 2.0
// final = base * 2.0
CHECK(ship->maxHp == Approx(baseHp * 2.0f));
CHECK(sim.admin().get<Health>(e).maxHp == Approx(baseHp * 2.0f));
}
// ---------------------------------------------------------------------------

View File

@@ -1,13 +1,13 @@
#include "catch.hpp"
#include <cmath>
#include <functional>
#include <string>
#include <vector>
#include <QVector2D>
#include "ConfigLoader.h"
#include "EcsComponents.h"
#include "EntityAdmin.h"
#include "EntityId.h"
#include "Ship.h"
#include "ShipSystem.h"
@@ -25,70 +25,65 @@ static GameConfig loadConfig()
TEST_CASE("ShipSystem: interceptor spawn has weapon and threatResponse, no cargo or repair",
"[ship]")
{
EntityAdmin admin;
const GameConfig cfg = loadConfig();
EntityId nextId = 1;
ShipSystem ss(cfg, [&nextId]() { return nextId++; });
ShipSystem ss(cfg, admin);
const EntityId id = ss.spawn("interceptor", 1, QVector2D(0.0f, 0.0f));
const Ship* ship = ss.findShip(id);
const entt::entity e = ss.spawn("interceptor", 1, QVector2D(0.0f, 0.0f));
REQUIRE(ship != nullptr);
REQUIRE(ship->weapon.has_value());
REQUIRE(ship->threatResponse.has_value());
REQUIRE_FALSE(ship->cargo.has_value());
REQUIRE_FALSE(ship->repairTool.has_value());
REQUIRE_FALSE(ship->repairBehavior.has_value());
REQUIRE_FALSE(ship->scrapCollector.has_value());
REQUIRE(admin.isValid(e));
REQUIRE(admin.hasAll<Weapon>(e));
REQUIRE(admin.hasAll<ThreatResponse>(e));
REQUIRE_FALSE(admin.hasAll<SalvageCargo>(e));
REQUIRE_FALSE(admin.hasAll<RepairTool>(e));
REQUIRE_FALSE(admin.hasAll<RepairBehavior>(e));
REQUIRE_FALSE(admin.hasAll<ScrapCollector>(e));
}
TEST_CASE("ShipSystem: interceptor level 1 stats match config formulas", "[ship]")
{
EntityAdmin admin;
const GameConfig cfg = loadConfig();
EntityId nextId = 1;
ShipSystem ss(cfg, [&nextId]() { return nextId++; });
ShipSystem ss(cfg, admin);
const EntityId id = ss.spawn("interceptor", 1, QVector2D(0.0f, 0.0f));
const Ship* ship = ss.findShip(id);
const entt::entity e = ss.spawn("interceptor", 1, QVector2D(0.0f, 0.0f));
REQUIRE(ship != nullptr);
// hp_formula = "40 + 5*x" at x=1 → 45
REQUIRE(ship->maxHp == Approx(45.0f));
REQUIRE(ship->hp == Approx(45.0f));
REQUIRE(admin.get<Health>(e).maxHp == Approx(45.0f));
REQUIRE(admin.get<Health>(e).hp == Approx(45.0f));
// damage_formula = "10 + 2*x" at x=1 → 12
REQUIRE(ship->weapon->damage == Approx(12.0f));
REQUIRE(admin.get<Weapon>(e).damage == Approx(12.0f));
// attack_range_formula = "150"
REQUIRE(ship->weapon->range == Approx(150.0f));
REQUIRE(admin.get<Weapon>(e).range == Approx(150.0f));
// sensor_range_formula = "200"
REQUIRE(ship->sensorRange == Approx(200.0f));
REQUIRE(admin.get<SensorRange>(e).value == Approx(200.0f));
// cooldownTicks starts at 0
REQUIRE(ship->weapon->cooldownTicks == Approx(0.0f));
REQUIRE(admin.get<Weapon>(e).cooldownTicks == Approx(0.0f));
}
TEST_CASE("ShipSystem: interceptor level 5 hp matches formula", "[ship]")
{
EntityAdmin admin;
const GameConfig cfg = loadConfig();
EntityId nextId = 1;
ShipSystem ss(cfg, [&nextId]() { return nextId++; });
ShipSystem ss(cfg, admin);
const EntityId id = ss.spawn("interceptor", 5, QVector2D(0.0f, 0.0f));
const Ship* ship = ss.findShip(id);
const entt::entity e = ss.spawn("interceptor", 5, QVector2D(0.0f, 0.0f));
// hp_formula = "40 + 5*x" at x=5 → 65
REQUIRE(ship->maxHp == Approx(65.0f));
REQUIRE(admin.get<Health>(e).maxHp == Approx(65.0f));
}
TEST_CASE("ShipSystem: interceptor level 0 maxSpeedPerTick matches formula / kTickRateHz", "[ship]")
{
EntityAdmin admin;
const GameConfig cfg = loadConfig();
EntityId nextId = 1;
ShipSystem ss(cfg, [&nextId]() { return nextId++; });
ShipSystem ss(cfg, admin);
const EntityId id = ss.spawn("interceptor", 0, QVector2D(0.0f, 0.0f));
const Ship* ship = ss.findShip(id);
const entt::entity e = ss.spawn("interceptor", 0, QVector2D(0.0f, 0.0f));
// speed_formula = "200 + 5*x" at x=0 → 200; maxSpeedPerTick = 200/30
const float expected = 200.0f / static_cast<float>(kTickRateHz);
REQUIRE(ship->maxSpeedPerTick == Approx(expected));
REQUIRE(admin.get<ShipDynamics>(e).maxSpeedPerTick == Approx(expected));
}
// ---------------------------------------------------------------------------
@@ -98,34 +93,31 @@ TEST_CASE("ShipSystem: interceptor level 0 maxSpeedPerTick matches formula / kTi
TEST_CASE("ShipSystem: salvage_ship spawn has cargo and scrapCollector, no weapon",
"[ship]")
{
EntityAdmin admin;
const GameConfig cfg = loadConfig();
EntityId nextId = 1;
ShipSystem ss(cfg, [&nextId]() { return nextId++; });
ShipSystem ss(cfg, admin);
const EntityId id = ss.spawn("salvage_ship", 1, QVector2D(0.0f, 0.0f));
const Ship* ship = ss.findShip(id);
const entt::entity e = ss.spawn("salvage_ship", 1, QVector2D(0.0f, 0.0f));
REQUIRE(ship != nullptr);
REQUIRE(ship->cargo.has_value());
REQUIRE(ship->scrapCollector.has_value());
REQUIRE_FALSE(ship->weapon.has_value());
REQUIRE_FALSE(ship->repairTool.has_value());
REQUIRE(admin.hasAll<SalvageCargo>(e));
REQUIRE(admin.hasAll<ScrapCollector>(e));
REQUIRE_FALSE(admin.hasAll<Weapon>(e));
REQUIRE_FALSE(admin.hasAll<RepairTool>(e));
}
TEST_CASE("ShipSystem: salvage_ship cargo capacity matches config", "[ship]")
{
EntityAdmin admin;
const GameConfig cfg = loadConfig();
EntityId nextId = 1;
ShipSystem ss(cfg, [&nextId]() { return nextId++; });
ShipSystem ss(cfg, admin);
const EntityId id = ss.spawn("salvage_ship", 1, QVector2D(0.0f, 0.0f));
const Ship* ship = ss.findShip(id);
const entt::entity e = ss.spawn("salvage_ship", 1, QVector2D(0.0f, 0.0f));
// cargo_capacity = 10
REQUIRE(ship->cargo->capacity == 10);
REQUIRE(ship->cargo->current == 0);
REQUIRE(ship->scrapCollector->deliveryBay == kInvalidEntityId);
REQUIRE_FALSE(ship->scrapCollector->scrapTarget.has_value());
REQUIRE(admin.get<SalvageCargo>(e).capacity == 10);
REQUIRE(admin.get<SalvageCargo>(e).current == 0);
REQUIRE(admin.get<ScrapCollector>(e).deliveryBay == kInvalidEntityId);
REQUIRE_FALSE(admin.get<ScrapCollector>(e).scrapTarget.has_value());
}
// ---------------------------------------------------------------------------
@@ -135,61 +127,59 @@ TEST_CASE("ShipSystem: salvage_ship cargo capacity matches config", "[ship]")
TEST_CASE("ShipSystem: repair_ship spawn has repairTool and repairBehavior, no weapon",
"[ship]")
{
EntityAdmin admin;
const GameConfig cfg = loadConfig();
EntityId nextId = 1;
ShipSystem ss(cfg, [&nextId]() { return nextId++; });
ShipSystem ss(cfg, admin);
const EntityId id = ss.spawn("repair_ship", 1, QVector2D(0.0f, 0.0f));
const Ship* ship = ss.findShip(id);
const entt::entity e = ss.spawn("repair_ship", 1, QVector2D(0.0f, 0.0f));
REQUIRE(ship != nullptr);
REQUIRE(ship->repairTool.has_value());
REQUIRE(ship->repairBehavior.has_value());
REQUIRE_FALSE(ship->weapon.has_value());
REQUIRE_FALSE(ship->cargo.has_value());
REQUIRE(admin.hasAll<RepairTool>(e));
REQUIRE(admin.hasAll<RepairBehavior>(e));
REQUIRE_FALSE(admin.hasAll<Weapon>(e));
REQUIRE_FALSE(admin.hasAll<SalvageCargo>(e));
}
TEST_CASE("ShipSystem: repair_ship level 1 repair stats match config formulas", "[ship]")
{
EntityAdmin admin;
const GameConfig cfg = loadConfig();
EntityId nextId = 1;
ShipSystem ss(cfg, [&nextId]() { return nextId++; });
ShipSystem ss(cfg, admin);
const EntityId id = ss.spawn("repair_ship", 1, QVector2D(0.0f, 0.0f));
const Ship* ship = ss.findShip(id);
const entt::entity e = ss.spawn("repair_ship", 1, QVector2D(0.0f, 0.0f));
// repair_rate_formula = "5 + x" at x=1 → 6
REQUIRE(ship->repairTool->ratePerTick == Approx(6.0f));
REQUIRE(admin.get<RepairTool>(e).ratePerTick == Approx(6.0f));
// repair_range_formula = "80"
REQUIRE(ship->repairTool->range == Approx(80.0f));
REQUIRE(admin.get<RepairTool>(e).range == Approx(80.0f));
}
// ---------------------------------------------------------------------------
// Entity ids and removal
// ---------------------------------------------------------------------------
TEST_CASE("ShipSystem: spawned ships receive strictly increasing entity ids", "[ship]")
TEST_CASE("ShipSystem: spawned ships are valid entities", "[ship]")
{
EntityAdmin admin;
const GameConfig cfg = loadConfig();
EntityId nextId = 1;
ShipSystem ss(cfg, [&nextId]() { return nextId++; });
ShipSystem ss(cfg, admin);
const EntityId id1 = ss.spawn("interceptor", 1, QVector2D(0.0f, 0.0f));
const EntityId id2 = ss.spawn("salvage_ship", 1, QVector2D(1.0f, 0.0f));
const entt::entity e1 = ss.spawn("interceptor", 1, QVector2D(0.0f, 0.0f));
const entt::entity e2 = ss.spawn("salvage_ship", 1, QVector2D(1.0f, 0.0f));
REQUIRE(id2 > id1);
REQUIRE(admin.isValid(e1));
REQUIRE(admin.isValid(e2));
REQUIRE(e1 != e2);
}
TEST_CASE("ShipSystem: despawn removes the ship", "[ship]")
{
EntityAdmin admin;
const GameConfig cfg = loadConfig();
EntityId nextId = 1;
ShipSystem ss(cfg, [&nextId]() { return nextId++; });
ShipSystem ss(cfg, admin);
const EntityId id = ss.spawn("interceptor", 1, QVector2D(0.0f, 0.0f));
REQUIRE(ss.findShip(id) != nullptr);
const entt::entity e = ss.spawn("interceptor", 1, QVector2D(0.0f, 0.0f));
REQUIRE(admin.isValid(e));
ss.despawn(id);
REQUIRE(ss.findShip(id) == nullptr);
ss.despawn(e);
REQUIRE_FALSE(admin.isValid(e));
}

View File

@@ -4,6 +4,8 @@
#include "BuildingSystem.h"
#include "BuildingType.h"
#include "ConfigLoader.h"
#include "EcsComponents.h"
#include "EntityAdmin.h"
#include "GameConfig.h"
#include "ItemType.h"
#include "Rotation.h"
@@ -51,6 +53,14 @@ static EntityId placeShipyard(Simulation& sim, const BuildingDef& yardDef)
100.0f, 100.0f);
}
static int countShips(Simulation& sim)
{
int n = 0;
sim.admin().forEach<ShipIdentity>(
[&n](entt::entity /*e*/, const ShipIdentity& /*si*/) { ++n; });
return n;
}
static void fillMaterials(Simulation& sim, EntityId yardId, const ShipDef& def)
{
sim.buildings().forEachBuilding([&](Building& b)
@@ -80,7 +90,7 @@ TEST_CASE("Shipyard: spawns a player ship after production cycle completes",
const BuildingDef* yardDef = findShipyardDef(sim.config());
REQUIRE(yardDef != nullptr);
const int shipsBefore = static_cast<int>(sim.ships().allShips().size());
const int shipsBefore = countShips(sim);
const EntityId yardId = placeShipyard(sim, *yardDef);
REQUIRE(yardId != kInvalidEntityId);
@@ -90,7 +100,7 @@ TEST_CASE("Shipyard: spawns a player ship after production cycle completes",
// First tick: materials consumed, production cycle starts — no ship yet.
sim.tick();
REQUIRE(static_cast<int>(sim.ships().allShips().size()) == shipsBefore);
REQUIRE(countShips(sim) == shipsBefore);
// Tick until the cycle completes.
const Tick cycleTicks = secondsToTicks(def->schematic.productionTimeSeconds);
@@ -98,21 +108,21 @@ TEST_CASE("Shipyard: spawns a player ship after production cycle completes",
{
sim.tick();
}
REQUIRE(static_cast<int>(sim.ships().allShips().size()) == shipsBefore);
REQUIRE(countShips(sim) == shipsBefore);
// Final tick: cycle completes, ship spawns.
sim.tick();
REQUIRE(static_cast<int>(sim.ships().allShips().size()) == shipsBefore + 1);
REQUIRE(countShips(sim) == shipsBefore + 1);
bool foundPlayerShip = false;
for (const Ship& ship : sim.ships().allShips())
{
if (!ship.isEnemy && ship.schematicId == def->id)
sim.admin().forEach<ShipIdentity, Faction>(
[&](entt::entity /*e*/, const ShipIdentity& si, const Faction& f)
{
foundPlayerShip = true;
break;
}
}
if (!f.isEnemy && si.schematicId == def->id)
{
foundPlayerShip = true;
}
});
REQUIRE(foundPlayerShip);
}
@@ -123,13 +133,13 @@ TEST_CASE("Shipyard: does not spawn without a schematic set", "[shipyard]")
const BuildingDef* yardDef = findShipyardDef(sim.config());
REQUIRE(yardDef != nullptr);
const int shipsBefore = static_cast<int>(sim.ships().allShips().size());
const int shipsBefore = countShips(sim);
placeShipyard(sim, *yardDef);
sim.tick();
REQUIRE(static_cast<int>(sim.ships().allShips().size()) == shipsBefore);
REQUIRE(countShips(sim) == shipsBefore);
}
TEST_CASE("Shipyard: does not spawn with insufficient materials", "[shipyard]")
@@ -141,7 +151,7 @@ TEST_CASE("Shipyard: does not spawn with insufficient materials", "[shipyard]")
const BuildingDef* yardDef = findShipyardDef(sim.config());
REQUIRE(yardDef != nullptr);
const int shipsBefore = static_cast<int>(sim.ships().allShips().size());
const int shipsBefore = countShips(sim);
const EntityId yardId = placeShipyard(sim, *yardDef);
sim.buildings().setRecipe(yardId, def->id);
@@ -153,7 +163,7 @@ TEST_CASE("Shipyard: does not spawn with insufficient materials", "[shipyard]")
sim.tick();
}
REQUIRE(static_cast<int>(sim.ships().allShips().size()) == shipsBefore);
REQUIRE(countShips(sim) == shipsBefore);
}
TEST_CASE("Shipyard: spawns a second ship after materials replenished", "[shipyard]")
@@ -176,7 +186,7 @@ TEST_CASE("Shipyard: spawns a second ship after materials replenished", "[shipya
{
sim.tick();
}
const int after1 = static_cast<int>(sim.ships().allShips().size());
const int after1 = countShips(sim);
// Second cycle: capture count immediately after the next spawn tick.
fillMaterials(sim, yardId, *def);
@@ -184,7 +194,7 @@ TEST_CASE("Shipyard: spawns a second ship after materials replenished", "[shipya
{
sim.tick();
}
const int after2 = static_cast<int>(sim.ships().allShips().size());
const int after2 = countShips(sim);
// After each cycle one ship was added; ships from prior cycles may have died
// from enemy fire, so we only assert the most-recent spawn is still present.

View File

@@ -6,6 +6,8 @@
#include "BuildingSystem.h"
#include "BuildingType.h"
#include "ConfigLoader.h"
#include "EcsComponents.h"
#include "EntityAdmin.h"
#include "Rotation.h"
#include "Ship.h"
#include "ShipSystem.h"
@@ -100,16 +102,23 @@ TEST_CASE("WaveSystem: Simulation pre-places HQ + 2 player + 2 enemy stations",
{
const Simulation sim(loadConfig(), 42);
int hqCount = 0;
int playerCount = 0;
int enemyCount = 0;
// HQ is still a Building (for belt integration).
int hqCount = 0;
for (const Building& b : sim.buildings().allBuildings())
{
if (b.type == BuildingType::Hq) { ++hqCount; }
else if (b.type == BuildingType::PlayerDefenceStation) { ++playerCount; }
else if (b.type == BuildingType::EnemyDefenceStation) { ++enemyCount; }
if (b.type == BuildingType::Hq) { ++hqCount; }
}
// Stations are ECS entities.
int playerCount = 0;
int enemyCount = 0;
sim.admin().forEach<StationBody, Faction>(
[&](entt::entity /*e*/, const StationBody& /*sb*/, const Faction& f)
{
if (f.isEnemy) { ++enemyCount; }
else { ++playerCount; }
});
REQUIRE(hqCount == 1);
REQUIRE(playerCount == 2);
REQUIRE(enemyCount == 2);
@@ -159,16 +168,18 @@ TEST_CASE("WaveSystem: player stations have weapon set", "[wave]")
const Simulation sim(loadConfig(), 42);
int armedPlayerStations = 0;
for (const Building& b : sim.buildings().allBuildings())
{
if (b.type == BuildingType::PlayerDefenceStation && b.weapon)
sim.admin().forEach<StationBody, Faction, StationWeapon>(
[&](entt::entity /*e*/, const StationBody& /*sb*/, const Faction& f,
const StationWeapon& w)
{
++armedPlayerStations;
REQUIRE(b.weapon->damage > 0.0f);
REQUIRE(b.weapon->range > 0.0f);
REQUIRE(b.weapon->fireRateHz > 0.0f);
}
}
if (!f.isEnemy)
{
++armedPlayerStations;
REQUIRE(w.damage > 0.0f);
REQUIRE(w.range > 0.0f);
REQUIRE(w.fireRateHz > 0.0f);
}
});
REQUIRE(armedPlayerStations == 2);
}
@@ -177,16 +188,18 @@ TEST_CASE("WaveSystem: enemy stations have weapon set", "[wave]")
const Simulation sim(loadConfig(), 42);
int armedEnemyStations = 0;
for (const Building& b : sim.buildings().allBuildings())
{
if (b.type == BuildingType::EnemyDefenceStation && b.weapon)
sim.admin().forEach<StationBody, Faction, StationWeapon>(
[&](entt::entity /*e*/, const StationBody& /*sb*/, const Faction& f,
const StationWeapon& w)
{
++armedEnemyStations;
REQUIRE(b.weapon->damage > 0.0f);
REQUIRE(b.weapon->range > 0.0f);
REQUIRE(b.weapon->fireRateHz > 0.0f);
}
}
if (f.isEnemy)
{
++armedEnemyStations;
REQUIRE(w.damage > 0.0f);
REQUIRE(w.range > 0.0f);
REQUIRE(w.fireRateHz > 0.0f);
}
});
REQUIRE(armedEnemyStations == 2);
}
@@ -207,14 +220,11 @@ TEST_CASE("WaveSystem: enemy ships spawn after the initial gap elapses", "[wave]
}
bool foundEnemyShip = false;
for (const Ship& s : sim.ships().allShips())
{
if (s.isEnemy)
sim.admin().forEach<ShipIdentity, Faction>(
[&](entt::entity /*e*/, const ShipIdentity& /*si*/, const Faction& f)
{
foundEnemyShip = true;
break;
}
}
if (f.isEnemy) { foundEnemyShip = true; }
});
REQUIRE(foundEnemyShip);
}
@@ -229,13 +239,14 @@ TEST_CASE("WaveSystem: only eligible ships (cost > 0) appear in waves", "[wave]"
sim.tick();
}
for (const Ship& s : sim.ships().allShips())
{
if (!s.isEnemy) { continue; }
// salvage_ship and repair_ship have cost_formula = "0" and must not spawn.
REQUIRE(s.schematicId != "salvage_ship");
REQUIRE(s.schematicId != "repair_ship");
}
sim.admin().forEach<ShipIdentity, Faction>(
[&](entt::entity /*e*/, const ShipIdentity& si, const Faction& f)
{
if (!f.isEnemy) { return; }
// salvage_ship and repair_ship have cost_formula = "0" and must not spawn.
REQUIRE(si.schematicId != "salvage_ship");
REQUIRE(si.schematicId != "repair_ship");
});
}
// ---------------------------------------------------------------------------
@@ -247,22 +258,21 @@ TEST_CASE("WaveSystem: destroying both enemy stations triggers a push", "[wave]"
Simulation sim(loadConfig(), 42);
// Damage both enemy stations to 0.
sim.buildings().forEachBuilding([](Building& b)
{
if (b.type == BuildingType::EnemyDefenceStation)
sim.admin().forEach<StationBody, Faction, Health>(
[](entt::entity /*e*/, const StationBody& /*sb*/, const Faction& f, Health& h)
{
b.hp = -1.0f;
}
});
if (f.isEnemy) { h.hp = -1.0f; }
});
sim.tick();
// After push: should have 2 new enemy stations.
int enemyCount = 0;
for (const Building& b : sim.buildings().allBuildings())
{
if (b.type == BuildingType::EnemyDefenceStation) { ++enemyCount; }
}
sim.admin().forEach<StationBody, Faction>(
[&](entt::entity /*e*/, const StationBody& /*sb*/, const Faction& f)
{
if (f.isEnemy) { ++enemyCount; }
});
REQUIRE(enemyCount == 2);
}
@@ -270,13 +280,11 @@ TEST_CASE("WaveSystem: push emits exactly one SchematicDropEvent", "[wave]")
{
Simulation sim(loadConfig(), 42);
sim.buildings().forEachBuilding([](Building& b)
{
if (b.type == BuildingType::EnemyDefenceStation)
sim.admin().forEach<StationBody, Faction, Health>(
[](entt::entity /*e*/, const StationBody& /*sb*/, const Faction& f, Health& h)
{
b.hp = -1.0f;
}
});
if (f.isEnemy) { h.hp = -1.0f; }
});
sim.tick();
@@ -288,13 +296,11 @@ TEST_CASE("WaveSystem: push schematic drop awards a known ship id", "[wave]")
{
Simulation sim(loadConfig(), 42);
sim.buildings().forEachBuilding([](Building& b)
{
if (b.type == BuildingType::EnemyDefenceStation)
sim.admin().forEach<StationBody, Faction, Health>(
[](entt::entity /*e*/, const StationBody& /*sb*/, const Faction& f, Health& h)
{
b.hp = -1.0f;
}
});
if (f.isEnemy) { h.hp = -1.0f; }
});
sim.tick();
const std::vector<SchematicDropEvent> events = sim.drainSchematicDropEvents();
@@ -319,28 +325,31 @@ TEST_CASE("WaveSystem: push places new enemy stations further right", "[wave]")
// Record the X position of the initial enemy stations.
int initialX = std::numeric_limits<int>::min();
for (const Building& b : sim.buildings().allBuildings())
{
if (b.type == BuildingType::EnemyDefenceStation)
sim.admin().forEach<StationBody, Faction>(
[&](entt::entity /*e*/, const StationBody& sb, const Faction& f)
{
if (b.anchor.x() > initialX) { initialX = b.anchor.x(); }
}
}
if (f.isEnemy && sb.anchor.x() > initialX)
{
initialX = sb.anchor.x();
}
});
sim.buildings().forEachBuilding([](Building& b)
{
if (b.type == BuildingType::EnemyDefenceStation) { b.hp = -1.0f; }
});
sim.admin().forEach<StationBody, Faction, Health>(
[](entt::entity /*e*/, const StationBody& /*sb*/, const Faction& f, Health& h)
{
if (f.isEnemy) { h.hp = -1.0f; }
});
sim.tick();
int newX = std::numeric_limits<int>::min();
for (const Building& b : sim.buildings().allBuildings())
{
if (b.type == BuildingType::EnemyDefenceStation)
sim.admin().forEach<StationBody, Faction>(
[&](entt::entity /*e*/, const StationBody& sb, const Faction& f)
{
if (b.anchor.x() > newX) { newX = b.anchor.x(); }
}
}
if (f.isEnemy && sb.anchor.x() > newX)
{
newX = sb.anchor.x();
}
});
REQUIRE(newX > initialX);
}