allow one target per repair tool module
This commit is contained in:
@@ -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