allow one target per repair tool module
This commit is contained in:
@@ -29,6 +29,43 @@
|
||||
#include "StationBodyComponent.h"
|
||||
#include "ThreatResponseBehaviorComponent.h"
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Shared helpers for repair targeting
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
struct RepairableInfo
|
||||
{
|
||||
entt::entity entity;
|
||||
QVector2D position;
|
||||
bool isEnemy;
|
||||
bool isShip;
|
||||
float hp;
|
||||
float maxHp;
|
||||
};
|
||||
|
||||
static std::vector<RepairableInfo> buildRepairables(EntityAdmin& admin)
|
||||
{
|
||||
std::vector<RepairableInfo> repairables;
|
||||
|
||||
admin.forEach<ShipIdentityComponent, PositionComponent, FactionComponent, HealthComponent>(
|
||||
[&repairables](entt::entity e, const ShipIdentityComponent& /*si*/,
|
||||
const PositionComponent& pos, const FactionComponent& f,
|
||||
const HealthComponent& h)
|
||||
{
|
||||
repairables.push_back({e, pos.value, f.isEnemy, true, h.hp, h.maxHp});
|
||||
});
|
||||
|
||||
admin.forEach<StationBodyComponent, PositionComponent, FactionComponent, HealthComponent>(
|
||||
[&repairables](entt::entity e, const StationBodyComponent& /*sb*/,
|
||||
const PositionComponent& pos, const FactionComponent& f,
|
||||
const HealthComponent& h)
|
||||
{
|
||||
repairables.push_back({e, pos.value, f.isEnemy, false, h.hp, h.maxHp});
|
||||
});
|
||||
|
||||
return repairables;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// tickHomeReturnBehavior (priority 4)
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -182,33 +219,7 @@ void AiSystem::tickThreatResponseBehavior(EntityAdmin& admin, const BuildingSyst
|
||||
|
||||
void AiSystem::tickRepairBehavior(EntityAdmin& admin, BuildingSystem& buildings)
|
||||
{
|
||||
// Snapshot all entities with health for repair targeting.
|
||||
struct RepairableInfo
|
||||
{
|
||||
entt::entity entity;
|
||||
QVector2D position;
|
||||
bool isEnemy;
|
||||
bool isShip;
|
||||
float hp;
|
||||
float maxHp;
|
||||
};
|
||||
std::vector<RepairableInfo> repairables;
|
||||
|
||||
admin.forEach<ShipIdentityComponent, PositionComponent, FactionComponent, HealthComponent>(
|
||||
[&repairables](entt::entity e, const ShipIdentityComponent& /*si*/,
|
||||
const PositionComponent& pos, const FactionComponent& f,
|
||||
const HealthComponent& h)
|
||||
{
|
||||
repairables.push_back({e, pos.value, f.isEnemy, true, h.hp, h.maxHp});
|
||||
});
|
||||
|
||||
admin.forEach<StationBodyComponent, PositionComponent, FactionComponent, HealthComponent>(
|
||||
[&repairables](entt::entity e, const StationBodyComponent& /*sb*/,
|
||||
const PositionComponent& pos, const FactionComponent& f,
|
||||
const HealthComponent& h)
|
||||
{
|
||||
repairables.push_back({e, pos.value, f.isEnemy, false, h.hp, h.maxHp});
|
||||
});
|
||||
std::vector<RepairableInfo> repairables = buildRepairables(admin);
|
||||
|
||||
// Snapshot enemy ships for threat detection.
|
||||
struct EnemyInfo
|
||||
@@ -232,8 +243,6 @@ void AiSystem::tickRepairBehavior(EntityAdmin& admin, BuildingSystem& buildings)
|
||||
PositionComponent& pos, FactionComponent& /*faction*/,
|
||||
SensorRangeComponent& sensor, MovementIntentComponent& intent)
|
||||
{
|
||||
const float repairRange = rb.maxRepairRange;
|
||||
|
||||
// Flee if enemy nearby.
|
||||
bool enemyNearby = false;
|
||||
for (const EnemyInfo& enemy : enemies)
|
||||
@@ -318,23 +327,57 @@ void AiSystem::tickRepairBehavior(EntityAdmin& admin, BuildingSystem& buildings)
|
||||
|
||||
void AiSystem::tickRepairTools(EntityAdmin& admin)
|
||||
{
|
||||
const std::vector<RepairableInfo> repairables = buildRepairables(admin);
|
||||
|
||||
admin.forEach<RepairToolComponent, ModuleOwnerComponent>(
|
||||
[&](entt::entity /*e*/, RepairToolComponent& rt, const ModuleOwnerComponent& owner)
|
||||
{
|
||||
if (!admin.hasAll<RepairBehaviorComponent>(owner.owner)) { return; }
|
||||
|
||||
const RepairBehaviorComponent& rb =
|
||||
admin.get<RepairBehaviorComponent>(owner.owner);
|
||||
if (!rb.currentTarget) { return; }
|
||||
const PositionComponent& ownerPos =
|
||||
admin.get<PositionComponent>(owner.owner);
|
||||
|
||||
const entt::entity target = *rb.currentTarget;
|
||||
if (!admin.isValid(target) || !admin.hasAll<HealthComponent>(target)) { return; }
|
||||
// Try the ship's preferred nav target first.
|
||||
if (rb.currentTarget)
|
||||
{
|
||||
const entt::entity preferred = *rb.currentTarget;
|
||||
if (admin.isValid(preferred) && admin.hasAll<HealthComponent>(preferred)
|
||||
&& admin.hasAll<PositionComponent>(preferred))
|
||||
{
|
||||
HealthComponent& th = admin.get<HealthComponent>(preferred);
|
||||
const float dist =
|
||||
(admin.get<PositionComponent>(preferred).value
|
||||
- ownerPos.value).length();
|
||||
if (th.hp > 0.0f && th.hp < th.maxHp && dist <= rt.range)
|
||||
{
|
||||
rt.currentTarget = rb.currentTarget;
|
||||
th.hp = std::min(th.hp + rt.ratePerTick, th.maxHp);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const PositionComponent& ownerPos = admin.get<PositionComponent>(owner.owner);
|
||||
const PositionComponent& targetPos = admin.get<PositionComponent>(target);
|
||||
const float dist = (targetPos.value - ownerPos.value).length();
|
||||
if (dist > rt.range) { return; }
|
||||
// Preferred target unavailable; scan for nearest damaged friendly in range.
|
||||
rt.currentTarget = std::nullopt;
|
||||
float bestDist = rt.range;
|
||||
for (const RepairableInfo& r : repairables)
|
||||
{
|
||||
if (r.isEnemy) { continue; }
|
||||
if (r.hp <= 0.0f || r.hp >= r.maxHp) { continue; }
|
||||
const float dist = (r.position - ownerPos.value).length();
|
||||
if (dist < bestDist)
|
||||
{
|
||||
bestDist = dist;
|
||||
rt.currentTarget = r.entity;
|
||||
}
|
||||
}
|
||||
|
||||
HealthComponent& targetHealth = admin.get<HealthComponent>(target);
|
||||
if (!rt.currentTarget) { return; }
|
||||
|
||||
HealthComponent& targetHealth =
|
||||
admin.get<HealthComponent>(*rt.currentTarget);
|
||||
targetHealth.hp = std::min(targetHealth.hp + rt.ratePerTick, targetHealth.maxHp);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -131,6 +131,28 @@ static entt::entity firstSalvageChild(EntityAdmin& admin, entt::entity ship)
|
||||
}
|
||||
|
||||
// Helpers to read ECS data for a ship entity.
|
||||
static entt::entity firstRepairChild(EntityAdmin& admin, entt::entity ship)
|
||||
{
|
||||
entt::entity result = entt::null;
|
||||
admin.forEach<RepairToolComponent, ModuleOwnerComponent>(
|
||||
[&](entt::entity ce, const RepairToolComponent&, const ModuleOwnerComponent& o)
|
||||
{
|
||||
if (o.owner == ship && result == entt::null) { result = ce; }
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
static std::vector<entt::entity> allRepairChildren(EntityAdmin& admin, entt::entity ship)
|
||||
{
|
||||
std::vector<entt::entity> result;
|
||||
admin.forEach<RepairToolComponent, ModuleOwnerComponent>(
|
||||
[&](entt::entity ce, const RepairToolComponent&, const ModuleOwnerComponent& o)
|
||||
{
|
||||
if (o.owner == ship) { result.push_back(ce); }
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
static const MovementIntentComponent& intent(EntityAdmin& a, entt::entity e)
|
||||
{
|
||||
return a.get<MovementIntentComponent>(e);
|
||||
@@ -395,6 +417,210 @@ TEST_CASE("BehaviorSystem: repair ship does not heal above maxHp", "[behavior]")
|
||||
REQUIRE(h.hp == Approx(h.maxHp));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// tickRepairTools — per-module targeting
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
TEST_CASE("BehaviorSystem: rt.currentTarget is set to preferred target when in range and damaged",
|
||||
"[behavior]")
|
||||
{
|
||||
Fixture f;
|
||||
const ShipLayoutConfig repairLayout = makeSingleModuleLayout("repair_tool_module");
|
||||
const entt::entity repairShip = f.ships.spawn("repair_ship", 1, QVector2D(0.0f, 0.0f),
|
||||
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;
|
||||
|
||||
f.ships.clearMovementIntents();
|
||||
f.ai.tickRepairBehavior(f.admin, f.buildings);
|
||||
f.ai.tickRepairTools(f.admin);
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
TEST_CASE("BehaviorSystem: repair module falls back to in-range target when preferred is out of repair range",
|
||||
"[behavior]")
|
||||
{
|
||||
Fixture f;
|
||||
const ShipLayoutConfig repairLayout = makeSingleModuleLayout("repair_tool_module");
|
||||
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));
|
||||
|
||||
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 entt::entity rc = firstRepairChild(f.admin, repairShip);
|
||||
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));
|
||||
}
|
||||
|
||||
TEST_CASE("BehaviorSystem: repair module falls back when preferred target is fully healed",
|
||||
"[behavior]")
|
||||
{
|
||||
Fixture f;
|
||||
const ShipLayoutConfig repairLayout = makeSingleModuleLayout("repair_tool_module");
|
||||
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));
|
||||
|
||||
// preferred is at full HP; only fallback needs repair
|
||||
f.admin.get<HealthComponent>(preferred).hp = f.admin.get<HealthComponent>(preferred).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);
|
||||
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",
|
||||
"[behavior]")
|
||||
{
|
||||
Fixture f;
|
||||
const ShipLayoutConfig repairLayout = makeSingleModuleLayout("repair_tool_module");
|
||||
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 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);
|
||||
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",
|
||||
"[behavior]")
|
||||
{
|
||||
Fixture f;
|
||||
const ShipLayoutConfig repairLayout = makeSingleModuleLayout("repair_tool_module");
|
||||
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)
|
||||
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);
|
||||
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",
|
||||
"[behavior]")
|
||||
{
|
||||
Fixture f;
|
||||
const ShipLayoutConfig repairLayout = makeTwoModuleLayout("repair_tool_module");
|
||||
const entt::entity repairShip = f.ships.spawn("repair_ship", 1, QVector2D(0.0f, 0.0f),
|
||||
false, repairLayout);
|
||||
const entt::entity targetA = f.ships.spawn("interceptor", 1, QVector2D(10.0f, 0.0f));
|
||||
|
||||
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);
|
||||
|
||||
// Both modules should have healed targetA — total increase is 2 * ratePerTick.
|
||||
const float ratePerTick = (5.0f + 1.0f) / static_cast<float>(kTickRateHz);
|
||||
REQUIRE(health(f.admin, targetA).hp == Approx(initHp + 2.0f * ratePerTick));
|
||||
|
||||
const std::vector<entt::entity> children = allRepairChildren(f.admin, repairShip);
|
||||
REQUIRE(children.size() == 2);
|
||||
for (const entt::entity child : children)
|
||||
{
|
||||
REQUIRE(f.admin.get<RepairToolComponent>(child).currentTarget == targetA);
|
||||
}
|
||||
}
|
||||
|
||||
TEST_CASE("BehaviorSystem: two repair modules both fall back and heal same target when preferred is fully healed",
|
||||
"[behavior]")
|
||||
{
|
||||
Fixture f;
|
||||
const ShipLayoutConfig repairLayout = makeTwoModuleLayout("repair_tool_module");
|
||||
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));
|
||||
|
||||
// preferred is at full HP so both modules must fall back
|
||||
f.admin.get<HealthComponent>(preferred).hp = f.admin.get<HealthComponent>(preferred).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;
|
||||
|
||||
f.ai.tickRepairTools(f.admin);
|
||||
|
||||
const float ratePerTick = (5.0f + 1.0f) / static_cast<float>(kTickRateHz);
|
||||
REQUIRE(health(f.admin, targetB).hp == Approx(initHp + 2.0f * ratePerTick));
|
||||
|
||||
const std::vector<entt::entity> children = allRepairChildren(f.admin, repairShip);
|
||||
REQUIRE(children.size() == 2);
|
||||
for (const entt::entity child : children)
|
||||
{
|
||||
REQUIRE(f.admin.get<RepairToolComponent>(child).currentTarget == targetB);
|
||||
}
|
||||
}
|
||||
|
||||
TEST_CASE("BehaviorSystem: tickRepairTools does not crash when owner lacks RepairBehaviorComponent",
|
||||
"[behavior]")
|
||||
{
|
||||
Fixture f;
|
||||
|
||||
// Bare child entity: has RepairToolComponent and ModuleOwnerComponent but owner has no
|
||||
// RepairBehaviorComponent.
|
||||
const entt::entity ownerShip = f.ships.spawn("interceptor", 1, QVector2D(0.0f, 0.0f));
|
||||
const entt::entity moduleEntity = f.admin.createModuleEntity();
|
||||
RepairToolComponent rt;
|
||||
rt.ratePerTick = 1.0f;
|
||||
rt.range = 10.0f;
|
||||
rt.currentTarget = std::nullopt;
|
||||
f.admin.addComponent<RepairToolComponent>(moduleEntity, rt);
|
||||
f.admin.addComponent<ModuleOwnerComponent>(moduleEntity, ModuleOwnerComponent{ownerShip});
|
||||
|
||||
// Must not crash.
|
||||
f.ai.tickRepairTools(f.admin);
|
||||
|
||||
REQUIRE_FALSE(f.admin.get<RepairToolComponent>(moduleEntity).currentTarget.has_value());
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// tickSalvageBehavior
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user