refactor AI system
This commit is contained in:
@@ -5,35 +5,40 @@
|
||||
#include <QPoint>
|
||||
#include <QVector2D>
|
||||
|
||||
#include "AdvanceBehavior.h"
|
||||
#include "AiSystem.h"
|
||||
#include "AttackBehavior.h"
|
||||
#include "BehaviorKind.h"
|
||||
#include "BeltSystem.h"
|
||||
#include "ModuleOwnerComponent.h"
|
||||
#include "ShipLayout.h"
|
||||
#include "Building.h"
|
||||
#include "BuildingSystem.h"
|
||||
#include "BuildingType.h"
|
||||
#include "ConfigLoader.h"
|
||||
#include "DeliverScrapBehavior.h"
|
||||
#include "DynamicBodyComponent.h"
|
||||
#include "DynamicBodySystem.h"
|
||||
#include "EntityAdmin.h"
|
||||
#include "FactionComponent.h"
|
||||
#include "HealthComponent.h"
|
||||
#include "HomeReturnBehaviorComponent.h"
|
||||
#include "ModuleOwnerComponent.h"
|
||||
#include "MovementIntentComponent.h"
|
||||
#include "MovementIntentSystem.h"
|
||||
#include "PositionComponent.h"
|
||||
#include "RallyBehaviorComponent.h"
|
||||
#include "RepairBehaviorComponent.h"
|
||||
#include "RepairBehavior.h"
|
||||
#include "RepairSystem.h"
|
||||
#include "RepairToolComponent.h"
|
||||
#include "RetreatBehavior.h"
|
||||
#include "Rotation.h"
|
||||
#include "SalvageBehaviorComponent.h"
|
||||
#include "SalvageCargoComponent.h"
|
||||
#include "SalvageScrapBehavior.h"
|
||||
#include "SalvagerSystem.h"
|
||||
#include "ScrapSystem.h"
|
||||
#include "SelectedBehaviorComponent.h"
|
||||
#include "SensorRangeComponent.h"
|
||||
#include "ShipIdentityComponent.h"
|
||||
#include "ShipLayout.h"
|
||||
#include "ShipSystem.h"
|
||||
#include "Tick.h"
|
||||
#include "ThreatResponseBehaviorComponent.h"
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Fixture
|
||||
@@ -55,6 +60,8 @@ struct Fixture
|
||||
BuildingSystem buildings;
|
||||
ShipSystem ships;
|
||||
AiSystem ai;
|
||||
SalvagerSystem salvager;
|
||||
RepairSystem repair;
|
||||
MovementIntentSystem movementIntent;
|
||||
DynamicBodySystem dynamicBody;
|
||||
ScrapSystem scraps;
|
||||
@@ -73,20 +80,32 @@ struct Fixture
|
||||
[](const std::string&) -> bool { return true; },
|
||||
rng)
|
||||
, ships(cfg, admin)
|
||||
, salvager(admin)
|
||||
, repair(admin)
|
||||
, scraps(admin)
|
||||
, tick(0)
|
||||
{
|
||||
}
|
||||
|
||||
// Phase 1-3: clear intents, evaluate behaviors, select winners, execute.
|
||||
void decide()
|
||||
{
|
||||
ships.clearMovementIntents();
|
||||
ai.tick(admin, buildings, scraps);
|
||||
}
|
||||
|
||||
// World mutation: collection/delivery and healing.
|
||||
void runModules()
|
||||
{
|
||||
salvager.tick(scraps, buildings);
|
||||
repair.tick();
|
||||
}
|
||||
|
||||
// Run one full behavior+movement tick (steps 7 and 10).
|
||||
void runBehaviorTick()
|
||||
{
|
||||
ships.clearMovementIntents();
|
||||
ai.tickHomeReturnBehavior(admin);
|
||||
ai.tickThreatResponseBehavior(admin, buildings);
|
||||
ai.tickRepairBehavior(admin, buildings);
|
||||
ai.tickRepairTools(admin);
|
||||
ai.tickSalvageBehavior(admin, scraps, buildings);
|
||||
decide();
|
||||
runModules();
|
||||
movementIntent.tick(admin);
|
||||
dynamicBody.tick(admin);
|
||||
++tick;
|
||||
@@ -131,7 +150,6 @@ static entt::entity firstSalvageChild(EntityAdmin& admin, entt::entity ship)
|
||||
return result;
|
||||
}
|
||||
|
||||
// Helpers to read ECS data for a ship entity.
|
||||
static entt::entity firstRepairChild(EntityAdmin& admin, entt::entity ship)
|
||||
{
|
||||
entt::entity result = entt::null;
|
||||
@@ -159,6 +177,11 @@ static const MovementIntentComponent& intent(EntityAdmin& a, entt::entity e)
|
||||
return a.get<MovementIntentComponent>(e);
|
||||
}
|
||||
|
||||
static BehaviorKind winnerOf(EntityAdmin& a, entt::entity e)
|
||||
{
|
||||
return a.get<SelectedBehaviorComponent>(e).winner;
|
||||
}
|
||||
|
||||
static const HealthComponent& health(EntityAdmin& a, entt::entity e)
|
||||
{
|
||||
return a.get<HealthComponent>(e);
|
||||
@@ -173,16 +196,16 @@ static const PositionComponent& pos(EntityAdmin& a, entt::entity e)
|
||||
// clearMovementIntents
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
TEST_CASE("BehaviorSystem: clearMovementIntents resets all ships to priority 0",
|
||||
TEST_CASE("BehaviorSystem: clearMovementIntents resets all ships to inactive",
|
||||
"[behavior]")
|
||||
{
|
||||
Fixture f;
|
||||
const entt::entity e = f.ships.spawn("interceptor", 1, QVector2D(0.0f, 0.0f));
|
||||
|
||||
f.admin.get<MovementIntentComponent>(e) = MovementIntentComponent{3, QVector2D(10.0f, 0.0f)};
|
||||
f.admin.get<MovementIntentComponent>(e) = MovementIntentComponent{true, QVector2D(10.0f, 0.0f)};
|
||||
f.ships.clearMovementIntents();
|
||||
|
||||
REQUIRE(intent(f.admin, e).priority == 0);
|
||||
REQUIRE_FALSE(intent(f.admin, e).active);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -196,7 +219,7 @@ TEST_CASE("BehaviorSystem: tickMovement advances ship by maxSpeed_tpt toward tar
|
||||
const entt::entity e = f.ships.spawn("interceptor", 1, QVector2D(0.0f, 0.0f));
|
||||
|
||||
const float speed = f.admin.get<DynamicBodyComponent>(e).maxSpeed_tpt;
|
||||
f.admin.get<MovementIntentComponent>(e) = MovementIntentComponent{1, QVector2D(100.0f, 0.0f)};
|
||||
f.admin.get<MovementIntentComponent>(e) = MovementIntentComponent{true, QVector2D(100.0f, 0.0f)};
|
||||
f.movementIntent.tick(f.admin);
|
||||
f.dynamicBody.tick(f.admin);
|
||||
|
||||
@@ -212,7 +235,7 @@ TEST_CASE("BehaviorSystem: tickMovement stops exactly at target without overshoo
|
||||
|
||||
const float speed = f.admin.get<DynamicBodyComponent>(e).maxSpeed_tpt;
|
||||
const QVector2D target(speed * 0.5f, 0.0f);
|
||||
f.admin.get<MovementIntentComponent>(e) = MovementIntentComponent{1, target};
|
||||
f.admin.get<MovementIntentComponent>(e) = MovementIntentComponent{true, target};
|
||||
f.movementIntent.tick(f.admin);
|
||||
f.dynamicBody.tick(f.admin);
|
||||
|
||||
@@ -221,60 +244,65 @@ TEST_CASE("BehaviorSystem: tickMovement stops exactly at target without overshoo
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// tickHomeReturnBehavior
|
||||
// RetreatBehavior
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
TEST_CASE("BehaviorSystem: tickHomeReturnBehavior does nothing when HP is above threshold",
|
||||
"[behavior]")
|
||||
TEST_CASE("BehaviorSystem: healthy player ship does not retreat", "[behavior]")
|
||||
{
|
||||
Fixture f;
|
||||
const entt::entity e = f.ships.spawn("interceptor", 1, QVector2D(0.0f, 0.0f));
|
||||
f.admin.addComponent<HomeReturnBehaviorComponent>(e, HomeReturnBehaviorComponent{0.3f, QVector2D(-10.0f, 0.0f)});
|
||||
f.admin.get<HealthComponent>(e).hp = f.admin.get<HealthComponent>(e).maxHp; // full HP
|
||||
|
||||
f.ships.clearMovementIntents();
|
||||
f.ai.tickHomeReturnBehavior(f.admin);
|
||||
f.decide();
|
||||
|
||||
REQUIRE(intent(f.admin, e).priority == 0);
|
||||
REQUIRE(winnerOf(f.admin, e) != BehaviorKind::Retreat);
|
||||
}
|
||||
|
||||
TEST_CASE("BehaviorSystem: tickHomeReturnBehavior writes priority-4 intent toward homePos when HP is low",
|
||||
"[behavior]")
|
||||
TEST_CASE("BehaviorSystem: low-HP player ship retreats toward the rally point", "[behavior]")
|
||||
{
|
||||
Fixture f;
|
||||
const QVector2D rallyPoint(-50.0f, 0.0f);
|
||||
f.ships.setRallyPoint(rallyPoint);
|
||||
const entt::entity e = f.ships.spawn("interceptor", 1, QVector2D(0.0f, 0.0f));
|
||||
const QVector2D homePos(-10.0f, 0.0f);
|
||||
f.admin.addComponent<HomeReturnBehaviorComponent>(e, HomeReturnBehaviorComponent{0.5f, homePos});
|
||||
f.admin.get<HealthComponent>(e).hp = f.admin.get<HealthComponent>(e).maxHp * 0.2f; // below threshold
|
||||
|
||||
f.ships.clearMovementIntents();
|
||||
f.ai.tickHomeReturnBehavior(f.admin);
|
||||
f.decide();
|
||||
|
||||
REQUIRE(intent(f.admin, e).priority == 4);
|
||||
REQUIRE(intent(f.admin, e).target.x() == Approx(homePos.x()));
|
||||
REQUIRE(winnerOf(f.admin, e) == BehaviorKind::Retreat);
|
||||
REQUIRE(intent(f.admin, e).active);
|
||||
REQUIRE(intent(f.admin, e).target.x() == Approx(rallyPoint.x()));
|
||||
}
|
||||
|
||||
TEST_CASE("BehaviorSystem: tickHomeReturnBehavior priority-4 beats tickThreatResponseBehavior priority-3",
|
||||
"[behavior]")
|
||||
TEST_CASE("BehaviorSystem: low-HP retreat outranks attacking a nearby enemy", "[behavior]")
|
||||
{
|
||||
Fixture f;
|
||||
const QVector2D rallyPoint(-50.0f, 0.0f);
|
||||
f.ships.setRallyPoint(rallyPoint);
|
||||
const entt::entity player = f.ships.spawn("interceptor", 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.admin.addComponent<HomeReturnBehaviorComponent>(player, HomeReturnBehaviorComponent{0.5f, homePos});
|
||||
f.admin.get<HealthComponent>(player).hp = f.admin.get<HealthComponent>(player).maxHp * 0.1f;
|
||||
|
||||
f.ships.clearMovementIntents();
|
||||
f.ai.tickHomeReturnBehavior(f.admin);
|
||||
f.ai.tickThreatResponseBehavior(f.admin, f.buildings);
|
||||
f.decide();
|
||||
|
||||
REQUIRE(intent(f.admin, player).priority == 4);
|
||||
REQUIRE(intent(f.admin, player).target.x() == Approx(homePos.x()));
|
||||
REQUIRE(winnerOf(f.admin, player) == BehaviorKind::Retreat);
|
||||
REQUIRE(intent(f.admin, player).target.x() == Approx(rallyPoint.x()));
|
||||
}
|
||||
|
||||
TEST_CASE("BehaviorSystem: enemy ships never retreat even at low HP", "[behavior]")
|
||||
{
|
||||
Fixture f;
|
||||
const entt::entity enemy = f.ships.spawn("interceptor", 1, QVector2D(0.0f, 0.0f),
|
||||
/*isEnemy=*/true);
|
||||
f.admin.get<HealthComponent>(enemy).hp = f.admin.get<HealthComponent>(enemy).maxHp * 0.05f;
|
||||
|
||||
f.decide();
|
||||
|
||||
REQUIRE_FALSE(f.admin.hasAll<RetreatBehavior>(enemy));
|
||||
REQUIRE(winnerOf(f.admin, enemy) != BehaviorKind::Retreat);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// tickThreatResponseBehavior — player ships
|
||||
// AttackBehavior — player ships
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
TEST_CASE("BehaviorSystem: player combat ship acquires nearest enemy ship in range",
|
||||
@@ -285,13 +313,13 @@ TEST_CASE("BehaviorSystem: player combat ship acquires nearest enemy ship in ran
|
||||
const entt::entity enemy = f.ships.spawn("interceptor", 1, QVector2D(10.0f, 0.0f),
|
||||
/*isEnemy=*/true);
|
||||
|
||||
f.ships.clearMovementIntents();
|
||||
f.ai.tickThreatResponseBehavior(f.admin, f.buildings);
|
||||
f.decide();
|
||||
|
||||
REQUIRE(f.admin.hasAll<ThreatResponseBehaviorComponent>(player));
|
||||
const ThreatResponseBehaviorComponent& threatResponseBehavior = f.admin.get<ThreatResponseBehaviorComponent>(player);
|
||||
REQUIRE(threatResponseBehavior.currentTarget.has_value());
|
||||
REQUIRE(*threatResponseBehavior.currentTarget == enemy);
|
||||
REQUIRE(f.admin.hasAll<AttackBehavior>(player));
|
||||
const AttackBehavior& attack = f.admin.get<AttackBehavior>(player);
|
||||
REQUIRE(attack.currentTarget.has_value());
|
||||
REQUIRE(*attack.currentTarget == enemy);
|
||||
REQUIRE(winnerOf(f.admin, player) == BehaviorKind::Attack);
|
||||
}
|
||||
|
||||
TEST_CASE("BehaviorSystem: player combat ship does not target friendly ships",
|
||||
@@ -301,11 +329,11 @@ TEST_CASE("BehaviorSystem: player combat ship does not target friendly ships",
|
||||
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.tickThreatResponseBehavior(f.admin, f.buildings);
|
||||
f.decide();
|
||||
|
||||
REQUIRE(f.admin.hasAll<ThreatResponseBehaviorComponent>(e1));
|
||||
REQUIRE_FALSE(f.admin.get<ThreatResponseBehaviorComponent>(e1).currentTarget.has_value());
|
||||
REQUIRE(f.admin.hasAll<AttackBehavior>(e1));
|
||||
REQUIRE_FALSE(f.admin.get<AttackBehavior>(e1).currentTarget.has_value());
|
||||
REQUIRE(winnerOf(f.admin, e1) != BehaviorKind::Attack);
|
||||
}
|
||||
|
||||
TEST_CASE("BehaviorSystem: player combat ship ignores enemy beyond engagement range",
|
||||
@@ -315,14 +343,13 @@ TEST_CASE("BehaviorSystem: player combat ship ignores enemy beyond engagement ra
|
||||
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.tickThreatResponseBehavior(f.admin, f.buildings);
|
||||
f.decide();
|
||||
|
||||
REQUIRE_FALSE(f.admin.get<ThreatResponseBehaviorComponent>(player).currentTarget.has_value());
|
||||
REQUIRE_FALSE(f.admin.get<AttackBehavior>(player).currentTarget.has_value());
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// tickThreatResponseBehavior — enemy ships
|
||||
// AttackBehavior — enemy ships
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
TEST_CASE("BehaviorSystem: enemy ship acquires nearest player ship in range",
|
||||
@@ -333,34 +360,34 @@ TEST_CASE("BehaviorSystem: enemy ship acquires nearest player ship in range",
|
||||
const entt::entity enemy = f.ships.spawn("interceptor", 1, QVector2D(10.0f, 0.0f),
|
||||
/*isEnemy=*/true);
|
||||
|
||||
f.ships.clearMovementIntents();
|
||||
f.ai.tickThreatResponseBehavior(f.admin, f.buildings);
|
||||
f.decide();
|
||||
|
||||
REQUIRE(f.admin.hasAll<ThreatResponseBehaviorComponent>(enemy));
|
||||
const ThreatResponseBehaviorComponent& threatResponseBehavior = f.admin.get<ThreatResponseBehaviorComponent>(enemy);
|
||||
REQUIRE(threatResponseBehavior.currentTarget.has_value());
|
||||
REQUIRE(*threatResponseBehavior.currentTarget == player);
|
||||
REQUIRE(f.admin.hasAll<AttackBehavior>(enemy));
|
||||
const AttackBehavior& attack = f.admin.get<AttackBehavior>(enemy);
|
||||
REQUIRE(attack.currentTarget.has_value());
|
||||
REQUIRE(*attack.currentTarget == player);
|
||||
REQUIRE(winnerOf(f.admin, enemy) == BehaviorKind::Attack);
|
||||
}
|
||||
|
||||
TEST_CASE("BehaviorSystem: enemy ship with no target writes leftward movement intent",
|
||||
TEST_CASE("BehaviorSystem: enemy ship with no target advances leftward",
|
||||
"[behavior]")
|
||||
{
|
||||
Fixture f;
|
||||
const entt::entity enemy = f.ships.spawn("interceptor", 1, QVector2D(100.0f, 0.0f),
|
||||
/*isEnemy=*/true);
|
||||
|
||||
f.ships.clearMovementIntents();
|
||||
f.ai.tickThreatResponseBehavior(f.admin, f.buildings);
|
||||
f.decide();
|
||||
|
||||
REQUIRE(intent(f.admin, enemy).priority == 3);
|
||||
REQUIRE(winnerOf(f.admin, enemy) == BehaviorKind::Advance);
|
||||
REQUIRE(intent(f.admin, enemy).active);
|
||||
REQUIRE(intent(f.admin, enemy).target.x() < 0.0f);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// tickRepairBehavior
|
||||
// RepairBehavior
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
TEST_CASE("BehaviorSystem: repair ship writes intent toward damaged friendly ship",
|
||||
TEST_CASE("BehaviorSystem: repair ship moves toward damaged friendly ship",
|
||||
"[behavior]")
|
||||
{
|
||||
Fixture f;
|
||||
@@ -371,10 +398,10 @@ TEST_CASE("BehaviorSystem: repair ship writes intent toward damaged friendly shi
|
||||
|
||||
f.admin.get<HealthComponent>(friendly).hp = f.admin.get<HealthComponent>(friendly).maxHp * 0.5f;
|
||||
|
||||
f.ships.clearMovementIntents();
|
||||
f.ai.tickRepairBehavior(f.admin, f.buildings);
|
||||
f.decide();
|
||||
|
||||
REQUIRE(intent(f.admin, repairShip).priority == 2);
|
||||
REQUIRE(winnerOf(f.admin, repairShip) == BehaviorKind::Repair);
|
||||
REQUIRE(intent(f.admin, repairShip).active);
|
||||
REQUIRE(intent(f.admin, repairShip).target.x() == Approx(5.0f));
|
||||
}
|
||||
|
||||
@@ -383,16 +410,14 @@ TEST_CASE("BehaviorSystem: repair ship heals damaged ally within repair range",
|
||||
{
|
||||
Fixture f;
|
||||
const ShipLayoutConfig repairLayout = makeSingleModuleLayout("repair_tool");
|
||||
const entt::entity repairShip = f.ships.spawn("repair_ship", 1, QVector2D(0.0f, 0.0f),
|
||||
false, repairLayout);
|
||||
f.ships.spawn("repair_ship", 1, QVector2D(0.0f, 0.0f), false, repairLayout);
|
||||
const entt::entity friendly = f.ships.spawn("interceptor", 1, QVector2D(1.0f, 0.0f));
|
||||
|
||||
const float initialHp = f.admin.get<HealthComponent>(friendly).maxHp * 0.5f;
|
||||
f.admin.get<HealthComponent>(friendly).hp = initialHp;
|
||||
|
||||
f.ships.clearMovementIntents();
|
||||
f.ai.tickRepairBehavior(f.admin, f.buildings);
|
||||
f.ai.tickRepairTools(f.admin);
|
||||
f.decide();
|
||||
f.runModules();
|
||||
|
||||
REQUIRE(health(f.admin, friendly).hp > initialHp);
|
||||
}
|
||||
@@ -408,9 +433,8 @@ TEST_CASE("BehaviorSystem: repair ship does not heal above maxHp", "[behavior]")
|
||||
|
||||
for (int i = 0; i < 5; ++i)
|
||||
{
|
||||
f.ships.clearMovementIntents();
|
||||
f.ai.tickRepairBehavior(f.admin, f.buildings);
|
||||
f.ai.tickRepairTools(f.admin);
|
||||
f.decide();
|
||||
f.runModules();
|
||||
}
|
||||
|
||||
const HealthComponent& h = health(f.admin, friendly);
|
||||
@@ -419,10 +443,10 @@ TEST_CASE("BehaviorSystem: repair ship does not heal above maxHp", "[behavior]")
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// tickRepairTools — per-module targeting
|
||||
// RepairSystem — per-module targeting
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
TEST_CASE("BehaviorSystem: rt.currentTarget is set to preferred target when in range and damaged",
|
||||
TEST_CASE("RepairSystem: tool heals the in-range damaged target chosen by the executor",
|
||||
"[behavior]")
|
||||
{
|
||||
Fixture f;
|
||||
@@ -431,118 +455,117 @@ TEST_CASE("BehaviorSystem: rt.currentTarget is set to preferred target when in r
|
||||
false, repairLayout);
|
||||
const entt::entity friendly = f.ships.spawn("interceptor", 1, QVector2D(10.0f, 0.0f));
|
||||
|
||||
f.admin.get<HealthComponent>(friendly).hp = f.admin.get<HealthComponent>(friendly).maxHp * 0.5f;
|
||||
const float initHp = f.admin.get<HealthComponent>(friendly).maxHp * 0.5f;
|
||||
f.admin.get<HealthComponent>(friendly).hp = initHp;
|
||||
|
||||
f.ships.clearMovementIntents();
|
||||
f.ai.tickRepairBehavior(f.admin, f.buildings);
|
||||
f.ai.tickRepairTools(f.admin);
|
||||
f.decide();
|
||||
f.runModules();
|
||||
|
||||
const entt::entity rc = firstRepairChild(f.admin, repairShip);
|
||||
REQUIRE(f.admin.isValid(rc));
|
||||
REQUIRE(f.admin.get<RepairToolComponent>(rc).currentTarget.has_value());
|
||||
REQUIRE(*f.admin.get<RepairToolComponent>(rc).currentTarget == friendly);
|
||||
REQUIRE(health(f.admin, friendly).hp > f.admin.get<HealthComponent>(friendly).maxHp * 0.5f);
|
||||
REQUIRE(health(f.admin, friendly).hp > initHp);
|
||||
}
|
||||
|
||||
TEST_CASE("BehaviorSystem: repair module falls back to in-range target when preferred is out of repair range",
|
||||
TEST_CASE("RepairSystem: tool falls back to in-range target when its target is out of repair range",
|
||||
"[behavior]")
|
||||
{
|
||||
Fixture f;
|
||||
const ShipLayoutConfig repairLayout = makeSingleModuleLayout("repair_tool");
|
||||
const entt::entity repairShip = f.ships.spawn("repair_ship", 1, QVector2D(0.0f, 0.0f),
|
||||
false, repairLayout);
|
||||
// preferred: within sensor range (200) but beyond repair range (80)
|
||||
const entt::entity preferred = f.ships.spawn("interceptor", 1, QVector2D(90.0f, 0.0f));
|
||||
// fallback: within repair range
|
||||
const entt::entity fallback = f.ships.spawn("interceptor", 1, QVector2D(20.0f, 0.0f));
|
||||
// out of repair range (80) but in sensor range (200)
|
||||
const entt::entity outOfRange = f.ships.spawn("interceptor", 1, QVector2D(90.0f, 0.0f));
|
||||
// within repair range
|
||||
const entt::entity fallback = f.ships.spawn("interceptor", 1, QVector2D(20.0f, 0.0f));
|
||||
|
||||
const float preferredInitHp = f.admin.get<HealthComponent>(preferred).maxHp * 0.5f;
|
||||
const float fallbackInitHp = f.admin.get<HealthComponent>(fallback).maxHp * 0.5f;
|
||||
f.admin.get<HealthComponent>(preferred).hp = preferredInitHp;
|
||||
f.admin.get<HealthComponent>(fallback).hp = fallbackInitHp;
|
||||
|
||||
// Force preferred as nav target without running full behavior tick.
|
||||
f.admin.get<RepairBehaviorComponent>(repairShip).currentTarget = preferred;
|
||||
|
||||
f.ai.tickRepairTools(f.admin);
|
||||
const float outInitHp = f.admin.get<HealthComponent>(outOfRange).maxHp * 0.5f;
|
||||
const float fallbackInitHp = f.admin.get<HealthComponent>(fallback).maxHp * 0.5f;
|
||||
f.admin.get<HealthComponent>(outOfRange).hp = outInitHp;
|
||||
f.admin.get<HealthComponent>(fallback).hp = fallbackInitHp;
|
||||
|
||||
// Seed the tool with an out-of-range target; RepairSystem must reacquire.
|
||||
const entt::entity rc = firstRepairChild(f.admin, repairShip);
|
||||
f.admin.get<RepairToolComponent>(rc).currentTarget = outOfRange;
|
||||
|
||||
f.repair.tick();
|
||||
|
||||
REQUIRE(f.admin.get<RepairToolComponent>(rc).currentTarget.has_value());
|
||||
REQUIRE(*f.admin.get<RepairToolComponent>(rc).currentTarget == fallback);
|
||||
REQUIRE(health(f.admin, fallback).hp > fallbackInitHp);
|
||||
REQUIRE(health(f.admin, preferred).hp == Approx(preferredInitHp));
|
||||
REQUIRE(health(f.admin, outOfRange).hp == Approx(outInitHp));
|
||||
}
|
||||
|
||||
TEST_CASE("BehaviorSystem: repair module falls back when preferred target is fully healed",
|
||||
TEST_CASE("RepairSystem: tool falls back when its target is fully healed",
|
||||
"[behavior]")
|
||||
{
|
||||
Fixture f;
|
||||
const ShipLayoutConfig repairLayout = makeSingleModuleLayout("repair_tool");
|
||||
const entt::entity repairShip = f.ships.spawn("repair_ship", 1, QVector2D(0.0f, 0.0f),
|
||||
false, repairLayout);
|
||||
const entt::entity preferred = f.ships.spawn("interceptor", 1, QVector2D(10.0f, 0.0f));
|
||||
const entt::entity fallback = f.ships.spawn("interceptor", 1, QVector2D(15.0f, 0.0f));
|
||||
const entt::entity healed = f.ships.spawn("interceptor", 1, QVector2D(10.0f, 0.0f));
|
||||
const entt::entity fallback = f.ships.spawn("interceptor", 1, QVector2D(15.0f, 0.0f));
|
||||
|
||||
// preferred is at full HP; only fallback needs repair
|
||||
f.admin.get<HealthComponent>(preferred).hp = f.admin.get<HealthComponent>(preferred).maxHp;
|
||||
f.admin.get<HealthComponent>(healed).hp = f.admin.get<HealthComponent>(healed).maxHp;
|
||||
const float fallbackInitHp = f.admin.get<HealthComponent>(fallback).maxHp * 0.5f;
|
||||
f.admin.get<HealthComponent>(fallback).hp = fallbackInitHp;
|
||||
|
||||
f.admin.get<RepairBehaviorComponent>(repairShip).currentTarget = preferred;
|
||||
|
||||
f.ai.tickRepairTools(f.admin);
|
||||
|
||||
const entt::entity rc = firstRepairChild(f.admin, repairShip);
|
||||
f.admin.get<RepairToolComponent>(rc).currentTarget = healed;
|
||||
|
||||
f.repair.tick();
|
||||
|
||||
REQUIRE(*f.admin.get<RepairToolComponent>(rc).currentTarget == fallback);
|
||||
REQUIRE(health(f.admin, fallback).hp > fallbackInitHp);
|
||||
}
|
||||
|
||||
TEST_CASE("BehaviorSystem: repair module falls back when preferred target is destroyed",
|
||||
TEST_CASE("RepairSystem: tool falls back when its target is destroyed",
|
||||
"[behavior]")
|
||||
{
|
||||
Fixture f;
|
||||
const ShipLayoutConfig repairLayout = makeSingleModuleLayout("repair_tool");
|
||||
const entt::entity repairShip = f.ships.spawn("repair_ship", 1, QVector2D(0.0f, 0.0f),
|
||||
false, repairLayout);
|
||||
const entt::entity preferred = f.ships.spawn("interceptor", 1, QVector2D(10.0f, 0.0f));
|
||||
const entt::entity fallback = f.ships.spawn("interceptor", 1, QVector2D(15.0f, 0.0f));
|
||||
const entt::entity gone = f.ships.spawn("interceptor", 1, QVector2D(10.0f, 0.0f));
|
||||
const entt::entity fallback = f.ships.spawn("interceptor", 1, QVector2D(15.0f, 0.0f));
|
||||
|
||||
const float fallbackInitHp = f.admin.get<HealthComponent>(fallback).maxHp * 0.5f;
|
||||
f.admin.get<HealthComponent>(fallback).hp = fallbackInitHp;
|
||||
|
||||
f.admin.get<RepairBehaviorComponent>(repairShip).currentTarget = preferred;
|
||||
f.ships.despawn(preferred);
|
||||
|
||||
f.ai.tickRepairTools(f.admin);
|
||||
|
||||
const entt::entity rc = firstRepairChild(f.admin, repairShip);
|
||||
f.admin.get<RepairToolComponent>(rc).currentTarget = gone;
|
||||
f.ships.despawn(gone);
|
||||
|
||||
f.repair.tick();
|
||||
|
||||
REQUIRE(*f.admin.get<RepairToolComponent>(rc).currentTarget == fallback);
|
||||
REQUIRE(health(f.admin, fallback).hp > fallbackInitHp);
|
||||
}
|
||||
|
||||
TEST_CASE("BehaviorSystem: rt.currentTarget is cleared when no repairable target is in range",
|
||||
TEST_CASE("RepairSystem: tool target is cleared when no repairable target is in range",
|
||||
"[behavior]")
|
||||
{
|
||||
Fixture f;
|
||||
const ShipLayoutConfig repairLayout = makeSingleModuleLayout("repair_tool");
|
||||
const entt::entity repairShip = f.ships.spawn("repair_ship", 1, QVector2D(0.0f, 0.0f),
|
||||
false, repairLayout);
|
||||
// friendly is beyond repair range (80) but within sensor range (200)
|
||||
// damaged but beyond repair range (80)
|
||||
const entt::entity outOfRange = f.ships.spawn("interceptor", 1, QVector2D(150.0f, 0.0f));
|
||||
|
||||
const float initHp = f.admin.get<HealthComponent>(outOfRange).maxHp * 0.5f;
|
||||
f.admin.get<HealthComponent>(outOfRange).hp = initHp;
|
||||
|
||||
f.admin.get<RepairBehaviorComponent>(repairShip).currentTarget = outOfRange;
|
||||
|
||||
f.ai.tickRepairTools(f.admin);
|
||||
|
||||
const entt::entity rc = firstRepairChild(f.admin, repairShip);
|
||||
f.admin.get<RepairToolComponent>(rc).currentTarget = outOfRange;
|
||||
|
||||
f.repair.tick();
|
||||
|
||||
REQUIRE_FALSE(f.admin.get<RepairToolComponent>(rc).currentTarget.has_value());
|
||||
REQUIRE(health(f.admin, outOfRange).hp == Approx(initHp));
|
||||
}
|
||||
|
||||
TEST_CASE("BehaviorSystem: two repair modules both heal preferred target additively",
|
||||
TEST_CASE("RepairSystem: two repair modules both heal the chosen target additively",
|
||||
"[behavior]")
|
||||
{
|
||||
Fixture f;
|
||||
@@ -554,9 +577,8 @@ TEST_CASE("BehaviorSystem: two repair modules both heal preferred target additiv
|
||||
const float initHp = f.admin.get<HealthComponent>(targetA).maxHp * 0.5f;
|
||||
f.admin.get<HealthComponent>(targetA).hp = initHp;
|
||||
|
||||
f.ships.clearMovementIntents();
|
||||
f.ai.tickRepairBehavior(f.admin, f.buildings);
|
||||
f.ai.tickRepairTools(f.admin);
|
||||
f.decide();
|
||||
f.runModules();
|
||||
|
||||
// Both modules should have healed targetA — total increase is 2 * ratePerTick.
|
||||
const float ratePerTick = (5.0f + 1.0f) / static_cast<float>(kTickRateHz);
|
||||
@@ -570,24 +592,27 @@ TEST_CASE("BehaviorSystem: two repair modules both heal preferred target additiv
|
||||
}
|
||||
}
|
||||
|
||||
TEST_CASE("BehaviorSystem: two repair modules both fall back and heal same target when preferred is fully healed",
|
||||
TEST_CASE("RepairSystem: two modules both fall back and heal the same target",
|
||||
"[behavior]")
|
||||
{
|
||||
Fixture f;
|
||||
const ShipLayoutConfig repairLayout = makeTwoModuleLayout("repair_tool");
|
||||
const entt::entity repairShip = f.ships.spawn("repair_ship", 1, QVector2D(0.0f, 0.0f),
|
||||
false, repairLayout);
|
||||
const entt::entity preferred = f.ships.spawn("interceptor", 1, QVector2D(10.0f, 0.0f));
|
||||
const entt::entity targetB = f.ships.spawn("interceptor", 1, QVector2D(20.0f, 0.0f));
|
||||
const entt::entity healed = f.ships.spawn("interceptor", 1, QVector2D(10.0f, 0.0f));
|
||||
const entt::entity targetB = f.ships.spawn("interceptor", 1, QVector2D(20.0f, 0.0f));
|
||||
|
||||
// preferred is at full HP so both modules must fall back
|
||||
f.admin.get<HealthComponent>(preferred).hp = f.admin.get<HealthComponent>(preferred).maxHp;
|
||||
f.admin.get<HealthComponent>(healed).hp = f.admin.get<HealthComponent>(healed).maxHp;
|
||||
const float initHp = f.admin.get<HealthComponent>(targetB).maxHp * 0.5f;
|
||||
f.admin.get<HealthComponent>(targetB).hp = initHp;
|
||||
|
||||
f.admin.get<RepairBehaviorComponent>(repairShip).currentTarget = preferred;
|
||||
// Seed both tools with the (fully-healed) target; they must reacquire targetB.
|
||||
for (const entt::entity child : allRepairChildren(f.admin, repairShip))
|
||||
{
|
||||
f.admin.get<RepairToolComponent>(child).currentTarget = healed;
|
||||
}
|
||||
|
||||
f.ai.tickRepairTools(f.admin);
|
||||
f.repair.tick();
|
||||
|
||||
const float ratePerTick = (5.0f + 1.0f) / static_cast<float>(kTickRateHz);
|
||||
REQUIRE(health(f.admin, targetB).hp == Approx(initHp + 2.0f * ratePerTick));
|
||||
@@ -600,13 +625,12 @@ TEST_CASE("BehaviorSystem: two repair modules both fall back and heal same targe
|
||||
}
|
||||
}
|
||||
|
||||
TEST_CASE("BehaviorSystem: tickRepairTools does not crash when owner lacks RepairBehaviorComponent",
|
||||
TEST_CASE("RepairSystem: does not crash when a tool's owner is not a repair ship",
|
||||
"[behavior]")
|
||||
{
|
||||
Fixture f;
|
||||
|
||||
// Bare child entity: has RepairToolComponent and ModuleOwnerComponent but owner has no
|
||||
// RepairBehaviorComponent.
|
||||
// Bare child entity: RepairToolComponent + ModuleOwnerComponent, owner is a combat ship.
|
||||
const entt::entity ownerShip = f.ships.spawn("interceptor", 1, QVector2D(0.0f, 0.0f));
|
||||
const entt::entity moduleEntity = f.admin.createModuleEntity();
|
||||
RepairToolComponent rt;
|
||||
@@ -616,17 +640,17 @@ TEST_CASE("BehaviorSystem: tickRepairTools does not crash when owner lacks Repai
|
||||
f.admin.addComponent<RepairToolComponent>(moduleEntity, rt);
|
||||
f.admin.addComponent<ModuleOwnerComponent>(moduleEntity, ModuleOwnerComponent{ownerShip});
|
||||
|
||||
// Must not crash.
|
||||
f.ai.tickRepairTools(f.admin);
|
||||
// Must not crash; no damaged friendly in range, so no target is set.
|
||||
f.repair.tick();
|
||||
|
||||
REQUIRE_FALSE(f.admin.get<RepairToolComponent>(moduleEntity).currentTarget.has_value());
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// tickSalvageBehavior
|
||||
// SalvageScrapBehavior / DeliverScrapBehavior
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
TEST_CASE("BehaviorSystem: salvage ship writes intent toward nearest scrap", "[behavior]")
|
||||
TEST_CASE("BehaviorSystem: salvage ship moves toward nearest scrap", "[behavior]")
|
||||
{
|
||||
Fixture f;
|
||||
const ShipLayoutConfig salvageLayout = makeSingleModuleLayout("salvager");
|
||||
@@ -636,10 +660,10 @@ TEST_CASE("BehaviorSystem: salvage ship writes intent toward nearest scrap", "[b
|
||||
const QVector2D scrapPos(100.0f, 0.0f);
|
||||
f.scraps.spawn(scrapPos, 1, 100000);
|
||||
|
||||
f.ships.clearMovementIntents();
|
||||
f.ai.tickSalvageBehavior(f.admin, f.scraps, f.buildings);
|
||||
f.decide();
|
||||
|
||||
REQUIRE(intent(f.admin, ship).priority == 1);
|
||||
REQUIRE(winnerOf(f.admin, ship) == BehaviorKind::SalvageScrap);
|
||||
REQUIRE(intent(f.admin, ship).active);
|
||||
REQUIRE(intent(f.admin, ship).target.x() == Approx(scrapPos.x()));
|
||||
}
|
||||
|
||||
@@ -651,8 +675,7 @@ TEST_CASE("BehaviorSystem: salvage ship collects scrap on arrival", "[behavior]"
|
||||
false, salvageLayout);
|
||||
const entt::entity scrapEntity = f.scraps.spawn(QVector2D(0.0f, 0.0f), 1, 100000);
|
||||
|
||||
f.ships.clearMovementIntents();
|
||||
f.ai.tickSalvageBehavior(f.admin, f.scraps, f.buildings);
|
||||
f.salvager.tick(f.scraps, f.buildings);
|
||||
|
||||
const entt::entity sc = firstSalvageChild(f.admin, ship);
|
||||
REQUIRE(f.admin.isValid(sc));
|
||||
@@ -687,11 +710,12 @@ TEST_CASE("BehaviorSystem: full-cargo salvage ship moves toward SalvageBay", "[b
|
||||
cargo.current = cargo.capacity; // full cargo
|
||||
}
|
||||
|
||||
f.ships.clearMovementIntents();
|
||||
f.ai.tickSalvageBehavior(f.admin, f.scraps, f.buildings);
|
||||
f.decide();
|
||||
|
||||
REQUIRE(winnerOf(f.admin, ship) == BehaviorKind::DeliverScrap);
|
||||
REQUIRE(f.admin.get<DeliverScrapBehavior>(ship).deliveryBay == bayId);
|
||||
const MovementIntentComponent& i = intent(f.admin, ship);
|
||||
REQUIRE(i.priority == 1);
|
||||
REQUIRE(i.active);
|
||||
REQUIRE(i.target.x() < pos(f.admin, ship).value.x());
|
||||
}
|
||||
|
||||
@@ -710,7 +734,7 @@ static int totalSalvageCurrent(EntityAdmin& admin, entt::entity ship)
|
||||
return total;
|
||||
}
|
||||
|
||||
TEST_CASE("BehaviorSystem: salvage module does not collect scrap beyond its collection range",
|
||||
TEST_CASE("SalvagerSystem: module does not collect scrap beyond its collection range",
|
||||
"[behavior]")
|
||||
{
|
||||
// collection_range_m_formula = "50"; scrap at distance 55 must not be collected.
|
||||
@@ -720,13 +744,12 @@ TEST_CASE("BehaviorSystem: salvage module does not collect scrap beyond its coll
|
||||
false, salvageLayout);
|
||||
f.scraps.spawn(QVector2D(55.0f, 0.0f), 1, 100000);
|
||||
|
||||
f.ships.clearMovementIntents();
|
||||
f.ai.tickSalvageBehavior(f.admin, f.scraps, f.buildings);
|
||||
f.salvager.tick(f.scraps, f.buildings);
|
||||
|
||||
REQUIRE(f.admin.get<SalvageCargoComponent>(firstSalvageChild(f.admin, ship)).current == 0);
|
||||
}
|
||||
|
||||
TEST_CASE("BehaviorSystem: salvage module collects scrap within its collection range",
|
||||
TEST_CASE("SalvagerSystem: module collects scrap within its collection range",
|
||||
"[behavior]")
|
||||
{
|
||||
// collection_range_m_formula = "50"; scrap at distance 45 must be collected.
|
||||
@@ -736,8 +759,7 @@ TEST_CASE("BehaviorSystem: salvage module collects scrap within its collection r
|
||||
false, salvageLayout);
|
||||
f.scraps.spawn(QVector2D(45.0f, 0.0f), 1, 100000);
|
||||
|
||||
f.ships.clearMovementIntents();
|
||||
f.ai.tickSalvageBehavior(f.admin, f.scraps, f.buildings);
|
||||
f.salvager.tick(f.scraps, f.buildings);
|
||||
|
||||
REQUIRE(f.admin.get<SalvageCargoComponent>(firstSalvageChild(f.admin, ship)).current == 1);
|
||||
}
|
||||
@@ -746,7 +768,7 @@ TEST_CASE("BehaviorSystem: salvage module collects scrap within its collection r
|
||||
// Collection rate (per-module cooldown)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
TEST_CASE("BehaviorSystem: salvage collection sets cooldown on module", "[behavior]")
|
||||
TEST_CASE("SalvagerSystem: collection sets cooldown on module", "[behavior]")
|
||||
{
|
||||
Fixture f;
|
||||
const ShipLayoutConfig salvageLayout = makeSingleModuleLayout("salvager");
|
||||
@@ -754,8 +776,7 @@ TEST_CASE("BehaviorSystem: salvage collection sets cooldown on module", "[behavi
|
||||
false, salvageLayout);
|
||||
f.scraps.spawn(QVector2D(0.0f, 0.0f), 1, 100000);
|
||||
|
||||
f.ships.clearMovementIntents();
|
||||
f.ai.tickSalvageBehavior(f.admin, f.scraps, f.buildings);
|
||||
f.salvager.tick(f.scraps, f.buildings);
|
||||
|
||||
const SalvageCargoComponent& cargo =
|
||||
f.admin.get<SalvageCargoComponent>(firstSalvageChild(f.admin, ship));
|
||||
@@ -763,7 +784,7 @@ TEST_CASE("BehaviorSystem: salvage collection sets cooldown on module", "[behavi
|
||||
REQUIRE(cargo.cooldownTicksRemaining == cargo.collectionIntervalTicks);
|
||||
}
|
||||
|
||||
TEST_CASE("BehaviorSystem: salvage module on cooldown does not collect scrap", "[behavior]")
|
||||
TEST_CASE("SalvagerSystem: module on cooldown does not collect scrap", "[behavior]")
|
||||
{
|
||||
Fixture f;
|
||||
const ShipLayoutConfig salvageLayout = makeSingleModuleLayout("salvager");
|
||||
@@ -773,13 +794,12 @@ TEST_CASE("BehaviorSystem: salvage module on cooldown does not collect scrap", "
|
||||
|
||||
f.admin.get<SalvageCargoComponent>(firstSalvageChild(f.admin, ship)).cooldownTicksRemaining = 10;
|
||||
|
||||
f.ships.clearMovementIntents();
|
||||
f.ai.tickSalvageBehavior(f.admin, f.scraps, f.buildings);
|
||||
f.salvager.tick(f.scraps, f.buildings);
|
||||
|
||||
REQUIRE(f.admin.get<SalvageCargoComponent>(firstSalvageChild(f.admin, ship)).current == 0);
|
||||
}
|
||||
|
||||
TEST_CASE("BehaviorSystem: salvage module collects again after cooldown expires", "[behavior]")
|
||||
TEST_CASE("SalvagerSystem: module collects again after cooldown expires", "[behavior]")
|
||||
{
|
||||
Fixture f;
|
||||
const ShipLayoutConfig salvageLayout = makeSingleModuleLayout("salvager");
|
||||
@@ -788,8 +808,7 @@ TEST_CASE("BehaviorSystem: salvage module collects again after cooldown expires"
|
||||
const entt::entity sc = firstSalvageChild(f.admin, ship);
|
||||
|
||||
f.scraps.spawn(QVector2D(0.0f, 0.0f), 1, 100000);
|
||||
f.ships.clearMovementIntents();
|
||||
f.ai.tickSalvageBehavior(f.admin, f.scraps, f.buildings);
|
||||
f.salvager.tick(f.scraps, f.buildings);
|
||||
REQUIRE(f.admin.get<SalvageCargoComponent>(sc).current == 1);
|
||||
|
||||
// Shorten cooldown to 1 tick and place a second scrap.
|
||||
@@ -797,8 +816,7 @@ TEST_CASE("BehaviorSystem: salvage module collects again after cooldown expires"
|
||||
f.scraps.spawn(QVector2D(0.0f, 0.0f), 1, 100000);
|
||||
|
||||
// Next tick: cooldown decrements to 0, module collects the second scrap.
|
||||
f.ships.clearMovementIntents();
|
||||
f.ai.tickSalvageBehavior(f.admin, f.scraps, f.buildings);
|
||||
f.salvager.tick(f.scraps, f.buildings);
|
||||
|
||||
REQUIRE(f.admin.get<SalvageCargoComponent>(sc).current == 2);
|
||||
}
|
||||
@@ -807,7 +825,7 @@ TEST_CASE("BehaviorSystem: salvage module collects again after cooldown expires"
|
||||
// Multiple salvage modules
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
TEST_CASE("BehaviorSystem: two salvage modules collect independently in same tick", "[behavior]")
|
||||
TEST_CASE("SalvagerSystem: two salvage modules collect independently in same tick", "[behavior]")
|
||||
{
|
||||
Fixture f;
|
||||
const ShipLayoutConfig salvageLayout = makeTwoModuleLayout("salvager");
|
||||
@@ -817,13 +835,12 @@ TEST_CASE("BehaviorSystem: two salvage modules collect independently in same tic
|
||||
f.scraps.spawn(QVector2D(0.0f, 0.0f), 1, 100000);
|
||||
f.scraps.spawn(QVector2D(0.0f, 0.0f), 1, 100000);
|
||||
|
||||
f.ships.clearMovementIntents();
|
||||
f.ai.tickSalvageBehavior(f.admin, f.scraps, f.buildings);
|
||||
f.salvager.tick(f.scraps, f.buildings);
|
||||
|
||||
REQUIRE(totalSalvageCurrent(f.admin, ship) == 2);
|
||||
}
|
||||
|
||||
TEST_CASE("BehaviorSystem: second salvage module does not collect when first module is on cooldown",
|
||||
TEST_CASE("SalvagerSystem: second salvage module does not collect when first is on cooldown",
|
||||
"[behavior]")
|
||||
{
|
||||
// One module on cooldown, one ready: only the ready module collects.
|
||||
@@ -847,8 +864,7 @@ TEST_CASE("BehaviorSystem: second salvage module does not collect when first mod
|
||||
f.scraps.spawn(QVector2D(0.0f, 0.0f), 1, 100000);
|
||||
f.scraps.spawn(QVector2D(0.0f, 0.0f), 1, 100000);
|
||||
|
||||
f.ships.clearMovementIntents();
|
||||
f.ai.tickSalvageBehavior(f.admin, f.scraps, f.buildings);
|
||||
f.salvager.tick(f.scraps, f.buildings);
|
||||
|
||||
// Only one module was ready, so only one scrap is collected.
|
||||
REQUIRE(totalSalvageCurrent(f.admin, ship) == 1);
|
||||
@@ -866,7 +882,7 @@ TEST_CASE("SensorRange: sensorRange is populated from config formula at spawn",
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sensor range — tickThreatResponseBehavior
|
||||
// Sensor range — AttackBehavior
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
TEST_CASE("SensorRange: player combat ship acquires enemy just inside sensor range", "[sensor]")
|
||||
@@ -876,10 +892,9 @@ TEST_CASE("SensorRange: player combat ship acquires enemy just inside sensor ran
|
||||
const entt::entity enemy = f.ships.spawn("interceptor", 1, QVector2D(190.0f, 0.0f),
|
||||
/*isEnemy=*/true);
|
||||
|
||||
f.ships.clearMovementIntents();
|
||||
f.ai.tickThreatResponseBehavior(f.admin, f.buildings);
|
||||
f.decide();
|
||||
|
||||
REQUIRE(f.admin.get<ThreatResponseBehaviorComponent>(player).currentTarget == enemy);
|
||||
REQUIRE(f.admin.get<AttackBehavior>(player).currentTarget == enemy);
|
||||
}
|
||||
|
||||
TEST_CASE("SensorRange: player combat ship ignores enemy just outside sensor range", "[sensor]")
|
||||
@@ -888,10 +903,9 @@ TEST_CASE("SensorRange: player combat ship ignores enemy just outside sensor ran
|
||||
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.tickThreatResponseBehavior(f.admin, f.buildings);
|
||||
f.decide();
|
||||
|
||||
REQUIRE_FALSE(f.admin.get<ThreatResponseBehaviorComponent>(player).currentTarget.has_value());
|
||||
REQUIRE_FALSE(f.admin.get<AttackBehavior>(player).currentTarget.has_value());
|
||||
}
|
||||
|
||||
TEST_CASE("SensorRange: enemy ship ignores player just outside sensor range", "[sensor]")
|
||||
@@ -901,29 +915,29 @@ TEST_CASE("SensorRange: enemy ship ignores player just outside sensor range", "[
|
||||
const entt::entity enemy = f.ships.spawn("interceptor", 1, QVector2D(210.0f, 0.0f),
|
||||
/*isEnemy=*/true);
|
||||
|
||||
f.ships.clearMovementIntents();
|
||||
f.ai.tickThreatResponseBehavior(f.admin, f.buildings);
|
||||
f.decide();
|
||||
|
||||
REQUIRE_FALSE(f.admin.get<ThreatResponseBehaviorComponent>(enemy).currentTarget.has_value());
|
||||
REQUIRE_FALSE(f.admin.get<AttackBehavior>(enemy).currentTarget.has_value());
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sensor range — tickRepairBehavior
|
||||
// Sensor range — RetreatBehavior (unarmed ships flee threats)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
TEST_CASE("SensorRange: repair ship retreats from enemy within sensor range", "[sensor]")
|
||||
{
|
||||
Fixture f;
|
||||
const QVector2D rallyPoint(-100.0f, 0.0f);
|
||||
f.ships.setRallyPoint(rallyPoint);
|
||||
const ShipLayoutConfig repairLayout = makeSingleModuleLayout("repair_tool");
|
||||
const entt::entity repairShip = f.ships.spawn("repair_ship", 1, QVector2D(0.0f, 0.0f),
|
||||
false, repairLayout);
|
||||
f.ships.spawn("interceptor", 1, QVector2D(200.0f, 0.0f), /*isEnemy=*/true);
|
||||
|
||||
f.ships.clearMovementIntents();
|
||||
f.ai.tickRepairBehavior(f.admin, f.buildings);
|
||||
f.decide();
|
||||
|
||||
REQUIRE(intent(f.admin, repairShip).priority == 2);
|
||||
REQUIRE(intent(f.admin, repairShip).target.x() < 0.0f);
|
||||
REQUIRE(winnerOf(f.admin, repairShip) == BehaviorKind::Retreat);
|
||||
REQUIRE(intent(f.admin, repairShip).target.x() == Approx(rallyPoint.x()));
|
||||
}
|
||||
|
||||
TEST_CASE("SensorRange: repair ship does not retreat from enemy beyond sensor range", "[sensor]")
|
||||
@@ -934,9 +948,9 @@ TEST_CASE("SensorRange: repair ship does not retreat from enemy beyond sensor ra
|
||||
false, repairLayout);
|
||||
f.ships.spawn("interceptor", 1, QVector2D(300.0f, 0.0f), /*isEnemy=*/true);
|
||||
|
||||
f.ships.clearMovementIntents();
|
||||
f.ai.tickRepairBehavior(f.admin, f.buildings);
|
||||
f.decide();
|
||||
|
||||
REQUIRE(winnerOf(f.admin, repairShip) != BehaviorKind::Retreat);
|
||||
REQUIRE(intent(f.admin, repairShip).target.x() > pos(f.admin, repairShip).value.x());
|
||||
}
|
||||
|
||||
@@ -949,14 +963,13 @@ TEST_CASE("SensorRange: repair ship does not acquire damaged ally beyond sensor
|
||||
const entt::entity friendly = f.ships.spawn("interceptor", 1, QVector2D(300.0f, 0.0f));
|
||||
f.admin.get<HealthComponent>(friendly).hp = f.admin.get<HealthComponent>(friendly).maxHp * 0.5f;
|
||||
|
||||
f.ships.clearMovementIntents();
|
||||
f.ai.tickRepairBehavior(f.admin, f.buildings);
|
||||
f.decide();
|
||||
|
||||
REQUIRE_FALSE(f.admin.get<RepairBehaviorComponent>(repairShip).currentTarget.has_value());
|
||||
REQUIRE_FALSE(f.admin.get<RepairBehavior>(repairShip).currentTarget.has_value());
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sensor range — tickSalvageBehavior
|
||||
// Sensor range — SalvageScrapBehavior
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
TEST_CASE("SensorRange: salvage ship ignores scrap beyond sensor range", "[sensor]")
|
||||
@@ -967,9 +980,8 @@ TEST_CASE("SensorRange: salvage ship ignores scrap beyond sensor range", "[senso
|
||||
false, salvageLayout);
|
||||
f.scraps.spawn(QVector2D(300.0f, 0.0f), 1, 100000);
|
||||
|
||||
f.ships.clearMovementIntents();
|
||||
f.ai.tickSalvageBehavior(f.admin, f.scraps, f.buildings);
|
||||
f.decide();
|
||||
|
||||
REQUIRE_FALSE(f.admin.get<SalvageBehaviorComponent>(ship).scrapTarget.has_value());
|
||||
REQUIRE_FALSE(f.admin.get<SalvageScrapBehavior>(ship).scrapTarget.has_value());
|
||||
REQUIRE(intent(f.admin, ship).target.x() > pos(f.admin, ship).value.x());
|
||||
}
|
||||
|
||||
@@ -17,9 +17,9 @@
|
||||
#include "ScrapSystem.h"
|
||||
#include "ShipSystem.h"
|
||||
#include "Simulation.h"
|
||||
#include "AttackBehavior.h"
|
||||
#include "StationBodyComponent.h"
|
||||
#include "Tick.h"
|
||||
#include "ThreatResponseBehaviorComponent.h"
|
||||
#include "WeaponComponent.h"
|
||||
|
||||
static GameConfig loadConfig()
|
||||
@@ -80,17 +80,18 @@ struct CombatFixture
|
||||
|
||||
void wireEnemyTarget(entt::entity enemy, entt::entity playerTarget)
|
||||
{
|
||||
// Set target on weapon child entity (CombatSystem syncs from ThreatResponse each tick,
|
||||
// but also setting directly ensures the first tick fires without waiting for sync).
|
||||
// Set the target directly on the weapon child entity. CombatSystem now
|
||||
// fires at whatever target a weapon already has (AttackExecutor would set
|
||||
// it in a full tick); setting it here drives CombatSystem in isolation.
|
||||
const entt::entity wc = findWeaponChild(admin, enemy);
|
||||
if (wc != entt::null)
|
||||
{
|
||||
admin.get<WeaponComponent>(wc).currentTarget = playerTarget;
|
||||
admin.get<WeaponComponent>(wc).cooldownTicks = 0.0f;
|
||||
}
|
||||
if (admin.hasAll<ThreatResponseBehaviorComponent>(enemy))
|
||||
if (admin.hasAll<AttackBehavior>(enemy))
|
||||
{
|
||||
admin.get<ThreatResponseBehaviorComponent>(enemy).currentTarget = playerTarget;
|
||||
admin.get<AttackBehavior>(enemy).currentTarget = playerTarget;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -6,22 +6,27 @@
|
||||
#include <QPoint>
|
||||
#include <QVector2D>
|
||||
|
||||
#include "AdvanceBehavior.h"
|
||||
#include "AttackBehavior.h"
|
||||
#include "BuildingId.h"
|
||||
#include "ConfigLoader.h"
|
||||
#include "DeliverScrapBehavior.h"
|
||||
#include "DynamicBodyComponent.h"
|
||||
#include "EntityAdmin.h"
|
||||
#include "HealthComponent.h"
|
||||
#include "ModuleOwnerComponent.h"
|
||||
#include "RepairBehaviorComponent.h"
|
||||
#include "RallyBehavior.h"
|
||||
#include "RepairBehavior.h"
|
||||
#include "RepairToolComponent.h"
|
||||
#include "RetreatBehavior.h"
|
||||
#include "Rotation.h"
|
||||
#include "SalvageBehaviorComponent.h"
|
||||
#include "SalvageCargoComponent.h"
|
||||
#include "SalvageScrapBehavior.h"
|
||||
#include "SelectedBehaviorComponent.h"
|
||||
#include "SensorRangeComponent.h"
|
||||
#include "ShipLayout.h"
|
||||
#include "ShipSystem.h"
|
||||
#include "Tick.h"
|
||||
#include "ThreatResponseBehaviorComponent.h"
|
||||
#include "WeaponComponent.h"
|
||||
|
||||
static GameConfig loadConfig()
|
||||
@@ -81,7 +86,7 @@ static ShipLayoutConfig makeSingleModuleLayout(const std::string& moduleId)
|
||||
// Combat ship (interceptor has default_modules = [laser_cannon])
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
TEST_CASE("ShipSystem: interceptor spawn has weapon child and threatResponse, no cargo or repair",
|
||||
TEST_CASE("ShipSystem: interceptor spawn has weapon child and attack behavior, no cargo or repair",
|
||||
"[ship]")
|
||||
{
|
||||
EntityAdmin admin;
|
||||
@@ -92,11 +97,47 @@ TEST_CASE("ShipSystem: interceptor spawn has weapon child and threatResponse, no
|
||||
|
||||
REQUIRE(admin.isValid(e));
|
||||
REQUIRE(admin.isValid(firstWeaponChild(admin, e)));
|
||||
REQUIRE(admin.hasAll<ThreatResponseBehaviorComponent>(e));
|
||||
REQUIRE(admin.hasAll<AttackBehavior>(e));
|
||||
// Every ship gets the baseline behaviors; a player combat ship also rallies
|
||||
// and can retreat.
|
||||
REQUIRE(admin.hasAll<AdvanceBehavior>(e));
|
||||
REQUIRE(admin.hasAll<SelectedBehaviorComponent>(e));
|
||||
REQUIRE(admin.hasAll<RallyBehavior>(e));
|
||||
REQUIRE(admin.hasAll<RetreatBehavior>(e));
|
||||
REQUIRE_FALSE(admin.isValid(firstSalvageChild(admin, e)));
|
||||
REQUIRE_FALSE(admin.isValid(firstRepairChild(admin, e)));
|
||||
REQUIRE_FALSE(admin.hasAll<RepairBehaviorComponent>(e));
|
||||
REQUIRE_FALSE(admin.hasAll<SalvageBehaviorComponent>(e));
|
||||
REQUIRE_FALSE(admin.hasAll<RepairBehavior>(e));
|
||||
REQUIRE_FALSE(admin.hasAll<SalvageScrapBehavior>(e));
|
||||
REQUIRE_FALSE(admin.hasAll<DeliverScrapBehavior>(e));
|
||||
}
|
||||
|
||||
TEST_CASE("ShipSystem: enemy combat ship has no rally or retreat behavior", "[ship]")
|
||||
{
|
||||
EntityAdmin admin;
|
||||
const GameConfig cfg = loadConfig();
|
||||
ShipSystem ss(cfg, admin);
|
||||
|
||||
const entt::entity e = ss.spawn("interceptor", 1, QVector2D(0.0f, 0.0f), /*isEnemy=*/true);
|
||||
|
||||
REQUIRE(admin.hasAll<AttackBehavior>(e));
|
||||
REQUIRE(admin.hasAll<AdvanceBehavior>(e));
|
||||
REQUIRE_FALSE(admin.hasAll<RallyBehavior>(e));
|
||||
REQUIRE_FALSE(admin.hasAll<RetreatBehavior>(e));
|
||||
}
|
||||
|
||||
TEST_CASE("ShipSystem: setRetreatEnabled(false) suppresses player retreat behavior", "[ship]")
|
||||
{
|
||||
EntityAdmin admin;
|
||||
const GameConfig cfg = loadConfig();
|
||||
ShipSystem ss(cfg, admin);
|
||||
ss.setRetreatEnabled(false);
|
||||
|
||||
const entt::entity e = ss.spawn("interceptor", 1, QVector2D(0.0f, 0.0f));
|
||||
|
||||
// Other player behaviors are unaffected; only retreat is suppressed.
|
||||
REQUIRE(admin.hasAll<AttackBehavior>(e));
|
||||
REQUIRE(admin.hasAll<RallyBehavior>(e));
|
||||
REQUIRE_FALSE(admin.hasAll<RetreatBehavior>(e));
|
||||
}
|
||||
|
||||
TEST_CASE("ShipSystem: interceptor level 1 stats match config formulas", "[ship]")
|
||||
@@ -161,7 +202,8 @@ TEST_CASE("ShipSystem: salvage_ship spawn with salvage module has cargo child an
|
||||
const entt::entity e = ss.spawn("salvage_ship", 1, QVector2D(0.0f, 0.0f), false, layout);
|
||||
|
||||
REQUIRE(admin.isValid(firstSalvageChild(admin, e)));
|
||||
REQUIRE(admin.hasAll<SalvageBehaviorComponent>(e));
|
||||
REQUIRE(admin.hasAll<SalvageScrapBehavior>(e));
|
||||
REQUIRE(admin.hasAll<DeliverScrapBehavior>(e));
|
||||
REQUIRE_FALSE(admin.isValid(firstWeaponChild(admin, e)));
|
||||
REQUIRE_FALSE(admin.isValid(firstRepairChild(admin, e)));
|
||||
}
|
||||
@@ -180,9 +222,9 @@ TEST_CASE("ShipSystem: salvage_ship cargo capacity matches config", "[ship]")
|
||||
REQUIRE(admin.isValid(sc));
|
||||
REQUIRE(admin.get<SalvageCargoComponent>(sc).capacity == 10);
|
||||
REQUIRE(admin.get<SalvageCargoComponent>(sc).current == 0);
|
||||
REQUIRE(admin.get<SalvageBehaviorComponent>(e).deliveryBay == kInvalidBuildingId);
|
||||
REQUIRE_FALSE(admin.get<SalvageBehaviorComponent>(e).scrapTarget.has_value());
|
||||
REQUIRE(admin.get<SalvageBehaviorComponent>(e).maxCollectionRange_tiles == Approx(50.0f));
|
||||
REQUIRE(admin.get<DeliverScrapBehavior>(e).deliveryBay == kInvalidBuildingId);
|
||||
REQUIRE_FALSE(admin.get<SalvageScrapBehavior>(e).scrapTarget.has_value());
|
||||
REQUIRE(admin.get<SalvageScrapBehavior>(e).maxCollectionRange_tiles == Approx(50.0f));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -200,7 +242,7 @@ TEST_CASE("ShipSystem: repair_ship spawn with repair module has repair child and
|
||||
const entt::entity e = ss.spawn("repair_ship", 1, QVector2D(0.0f, 0.0f), false, layout);
|
||||
|
||||
REQUIRE(admin.isValid(firstRepairChild(admin, e)));
|
||||
REQUIRE(admin.hasAll<RepairBehaviorComponent>(e));
|
||||
REQUIRE(admin.hasAll<RepairBehavior>(e));
|
||||
REQUIRE_FALSE(admin.isValid(firstWeaponChild(admin, e)));
|
||||
REQUIRE_FALSE(admin.isValid(firstSalvageChild(admin, e)));
|
||||
}
|
||||
@@ -221,7 +263,7 @@ TEST_CASE("ShipSystem: repair_ship level 1 repair stats match config formulas",
|
||||
REQUIRE(admin.get<RepairToolComponent>(rc).ratePerTick == Approx(expectedRate));
|
||||
// repair_range_m_formula = "800" m → 800/10 = 80 tiles
|
||||
REQUIRE(admin.get<RepairToolComponent>(rc).range_tiles == Approx(80.0f));
|
||||
REQUIRE(admin.get<RepairBehaviorComponent>(e).maxRepairRange_tiles == Approx(80.0f));
|
||||
REQUIRE(admin.get<RepairBehavior>(e).maxRepairRange_tiles == Approx(80.0f));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user