From 090dc64bc47f2a323b26996cfdb0a7fe8eedd340 Mon Sep 17 00:00:00 2001 From: mlangkabel Date: Tue, 2 Jun 2026 22:06:54 +0200 Subject: [PATCH] allow one target per repair tool module --- docs/requirements.md | 2 + src/lib/ecs/system/AiSystem.cpp | 117 +++++++++++------ src/test/BehaviorSystemTest.cpp | 226 ++++++++++++++++++++++++++++++++ 3 files changed, 308 insertions(+), 37 deletions(-) diff --git a/docs/requirements.md b/docs/requirements.md index c915dc6..f16affb 100644 --- a/docs/requirements.md +++ b/docs/requirements.md @@ -166,6 +166,8 @@ Modules in `modules.toml` define a `surface_mask` — a list of strings that des Each salvage module instance operates independently: it has its own cargo hold (`cargo_capacity`), collection range (`collection_range`), and collection rate (`collection_rate`, in collections per second). After collecting a piece of scrap, the module cannot collect again until `1 / collection_rate` seconds have elapsed. A ship with multiple salvage modules can therefore collect multiple pieces of scrap per tick (one per ready module), and installs of different module types may have different ranges and rates. The ship navigates based on the maximum collection range across all installed salvage modules. - REQ-SHP-REPAIR: Ships with at least one **repair module** (player) — patrol by moving forward (rightward, away from the asteroid) while searching sensor range. If a damaged player defence station or player ship enters sensor range, move to it and repair. If an enemy ship enters sensor range while not currently repairing, turn back (move toward the asteroid) until the enemy is no longer in sensor range, then resume patrol. The player can configure the target priority per shipyard: - Defence stations first / ships first / nearest target. + + Each repair module instance operates independently: it has its own repair rate (`repair_rate`) and repair range (`repair_range`). On each tick, a module first attempts to heal the ship's current behavior-level navigation target if that target is within the module's `repair_range` and is damaged (HP above zero and below maximum HP). If those conditions are not met — because the target is out of the module's `repair_range`, already at full health, or destroyed — the module independently searches for the nearest damaged friendly (player ship or player defence station) within its own `repair_range` and heals that instead. If no valid target is found within range, the module idles. A ship with multiple repair modules can therefore heal different targets simultaneously. Navigation is driven solely by the behavior-level target; individual module fallback targets do not affect which direction the ship moves. - REQ-SHP-ENEMY-AI: **Enemy ships** — engage the closest valid target (player defence station, HQ, or player ship) within their sensor range. If no target is in sensor range, they move toward the asteroid (leftward in world coordinates). - REQ-SHP-SCHEMATICS: The player selects a schematic per shipyard by clicking it. New schematics are unlocked automatically when an enemy defence station set is destroyed (REQ-DEF-SCHEMATIC-DROP) — there is no physical loot to collect. diff --git a/src/lib/ecs/system/AiSystem.cpp b/src/lib/ecs/system/AiSystem.cpp index 930777d..2032d4b 100644 --- a/src/lib/ecs/system/AiSystem.cpp +++ b/src/lib/ecs/system/AiSystem.cpp @@ -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 buildRepairables(EntityAdmin& admin) +{ + std::vector repairables; + + admin.forEach( + [&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( + [&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 repairables; - - admin.forEach( - [&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( - [&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 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 repairables = buildRepairables(admin); + admin.forEach( [&](entt::entity /*e*/, RepairToolComponent& rt, const ModuleOwnerComponent& owner) { if (!admin.hasAll(owner.owner)) { return; } + const RepairBehaviorComponent& rb = admin.get(owner.owner); - if (!rb.currentTarget) { return; } + const PositionComponent& ownerPos = + admin.get(owner.owner); - const entt::entity target = *rb.currentTarget; - if (!admin.isValid(target) || !admin.hasAll(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(preferred) + && admin.hasAll(preferred)) + { + HealthComponent& th = admin.get(preferred); + const float dist = + (admin.get(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(owner.owner); - const PositionComponent& targetPos = admin.get(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(target); + if (!rt.currentTarget) { return; } + + HealthComponent& targetHealth = + admin.get(*rt.currentTarget); targetHealth.hp = std::min(targetHealth.hp + rt.ratePerTick, targetHealth.maxHp); }); } diff --git a/src/test/BehaviorSystemTest.cpp b/src/test/BehaviorSystemTest.cpp index b2e97a9..17f0ab5 100644 --- a/src/test/BehaviorSystemTest.cpp +++ b/src/test/BehaviorSystemTest.cpp @@ -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( + [&](entt::entity ce, const RepairToolComponent&, const ModuleOwnerComponent& o) + { + if (o.owner == ship && result == entt::null) { result = ce; } + }); + return result; +} + +static std::vector allRepairChildren(EntityAdmin& admin, entt::entity ship) +{ + std::vector result; + admin.forEach( + [&](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(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(friendly).hp = f.admin.get(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(rc).currentTarget.has_value()); + REQUIRE(*f.admin.get(rc).currentTarget == friendly); + REQUIRE(health(f.admin, friendly).hp > f.admin.get(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(preferred).maxHp * 0.5f; + const float fallbackInitHp = f.admin.get(fallback).maxHp * 0.5f; + f.admin.get(preferred).hp = preferredInitHp; + f.admin.get(fallback).hp = fallbackInitHp; + + // Force preferred as nav target without running full behavior tick. + f.admin.get(repairShip).currentTarget = preferred; + + f.ai.tickRepairTools(f.admin); + + const entt::entity rc = firstRepairChild(f.admin, repairShip); + REQUIRE(f.admin.get(rc).currentTarget.has_value()); + REQUIRE(*f.admin.get(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(preferred).hp = f.admin.get(preferred).maxHp; + const float fallbackInitHp = f.admin.get(fallback).maxHp * 0.5f; + f.admin.get(fallback).hp = fallbackInitHp; + + f.admin.get(repairShip).currentTarget = preferred; + + f.ai.tickRepairTools(f.admin); + + const entt::entity rc = firstRepairChild(f.admin, repairShip); + REQUIRE(*f.admin.get(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(fallback).maxHp * 0.5f; + f.admin.get(fallback).hp = fallbackInitHp; + + f.admin.get(repairShip).currentTarget = preferred; + f.ships.despawn(preferred); + + f.ai.tickRepairTools(f.admin); + + const entt::entity rc = firstRepairChild(f.admin, repairShip); + REQUIRE(*f.admin.get(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(outOfRange).maxHp * 0.5f; + f.admin.get(outOfRange).hp = initHp; + + f.admin.get(repairShip).currentTarget = outOfRange; + + f.ai.tickRepairTools(f.admin); + + const entt::entity rc = firstRepairChild(f.admin, repairShip); + REQUIRE_FALSE(f.admin.get(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(targetA).maxHp * 0.5f; + f.admin.get(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(kTickRateHz); + REQUIRE(health(f.admin, targetA).hp == Approx(initHp + 2.0f * ratePerTick)); + + const std::vector children = allRepairChildren(f.admin, repairShip); + REQUIRE(children.size() == 2); + for (const entt::entity child : children) + { + REQUIRE(f.admin.get(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(preferred).hp = f.admin.get(preferred).maxHp; + const float initHp = f.admin.get(targetB).maxHp * 0.5f; + f.admin.get(targetB).hp = initHp; + + f.admin.get(repairShip).currentTarget = preferred; + + f.ai.tickRepairTools(f.admin); + + const float ratePerTick = (5.0f + 1.0f) / static_cast(kTickRateHz); + REQUIRE(health(f.admin, targetB).hp == Approx(initHp + 2.0f * ratePerTick)); + + const std::vector children = allRepairChildren(f.admin, repairShip); + REQUIRE(children.size() == 2); + for (const entt::entity child : children) + { + REQUIRE(f.admin.get(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(moduleEntity, rt); + f.admin.addComponent(moduleEntity, ModuleOwnerComponent{ownerShip}); + + // Must not crash. + f.ai.tickRepairTools(f.admin); + + REQUIRE_FALSE(f.admin.get(moduleEntity).currentTarget.has_value()); +} + // --------------------------------------------------------------------------- // tickSalvageBehavior // ---------------------------------------------------------------------------