allow one target per repair tool module

This commit is contained in:
2026-06-02 22:06:54 +02:00
parent 64f7c9dcc1
commit 090dc64bc4
3 changed files with 308 additions and 37 deletions

View File

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

View File

@@ -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
// ---------------------------------------------------------------------------