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

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