diff --git a/src/balancing/ArenaSimulation.cpp b/src/balancing/ArenaSimulation.cpp index 5fc6844..791ce5b 100644 --- a/src/balancing/ArenaSimulation.cpp +++ b/src/balancing/ArenaSimulation.cpp @@ -5,10 +5,12 @@ #include +#include "AiSystem.h" #include "Building.h" #include "BuildingSystem.h" #include "BuildingType.h" #include "CombatSystem.h" +#include "MovementSystem.h" #include "ScrapSystem.h" #include "Ship.h" #include "ShipSystem.h" @@ -42,8 +44,10 @@ ArenaSimulation::ArenaSimulation(const GameConfig& gameConfig, m_shipSystem = std::make_unique( m_gameConfig, [this]() { return allocateId(); }); - m_combatSystem = std::make_unique(m_gameConfig); - m_scrapSystem = std::make_unique([this]() { return allocateId(); }); + m_aiSystem = std::make_unique(); + m_movementSystem = std::make_unique(); + m_combatSystem = std::make_unique(m_gameConfig); + m_scrapSystem = std::make_unique([this]() { return allocateId(); }); placeStructures(); spawnShips(); @@ -250,10 +254,10 @@ void ArenaSimulation::tick() { // Ship behavior systems (tick step 7). m_shipSystem->clearMovementIntents(); - m_shipSystem->tickHomeReturn(); - m_shipSystem->tickThreatResponse(*m_buildingSystem); - m_shipSystem->tickRepairBehavior(*m_buildingSystem); - m_shipSystem->tickScrapCollector(*m_scrapSystem, *m_buildingSystem); + m_aiSystem->tickHomeReturn(*m_shipSystem); + m_aiSystem->tickThreatResponse(*m_shipSystem, *m_buildingSystem); + m_aiSystem->tickRepairBehavior(*m_shipSystem, *m_buildingSystem); + m_aiSystem->tickScrapCollector(*m_shipSystem, *m_scrapSystem, *m_buildingSystem); // Combat resolution (tick step 8). std::vector fireEvents; @@ -265,7 +269,7 @@ void ArenaSimulation::tick() tickDeaths(); // Movement (tick step 10). - m_shipSystem->tickMovement(); + m_movementSystem->tick(*m_shipSystem); // Scrap despawn (tick step 11). m_scrapSystem->tickDespawn(m_currentTick); diff --git a/src/balancing/ArenaSimulation.h b/src/balancing/ArenaSimulation.h index 6990f41..c322d7c 100644 --- a/src/balancing/ArenaSimulation.h +++ b/src/balancing/ArenaSimulation.h @@ -14,8 +14,10 @@ #include "GameConfig.h" #include "Tick.h" +class AiSystem; class BuildingSystem; class CombatSystem; +class MovementSystem; class ShipSystem; class ScrapSystem; @@ -81,9 +83,11 @@ private: BeltSystem m_beltSystem; std::unique_ptr m_buildingSystem; - std::unique_ptr m_shipSystem; - std::unique_ptr m_combatSystem; - std::unique_ptr m_scrapSystem; + std::unique_ptr m_shipSystem; + std::unique_ptr m_aiSystem; + std::unique_ptr m_movementSystem; + std::unique_ptr m_combatSystem; + std::unique_ptr m_scrapSystem; EntityId m_team1HqId; EntityId m_team2HqId; diff --git a/src/lib/sim/AiSystem.cpp b/src/lib/sim/AiSystem.cpp new file mode 100644 index 0000000..537142f --- /dev/null +++ b/src/lib/sim/AiSystem.cpp @@ -0,0 +1,463 @@ +#include "AiSystem.h" + +#include +#include + +#include + +#include "Building.h" +#include "BuildingSystem.h" +#include "BuildingType.h" +#include "EntityId.h" +#include "MovementIntent.h" +#include "Scrap.h" +#include "ScrapSystem.h" +#include "Ship.h" +#include "ShipSystem.h" + +static QVector2D buildingCenter(const Building& b) +{ + return QVector2D(b.anchor.x() + b.footprint.width() / 2.0f, + b.anchor.y() + b.footprint.height() / 2.0f); +} + +static bool isTargetValid(EntityId id, float range, const Ship& ship, + const ShipSystem& ships, + const BuildingSystem& buildings) +{ + if (id == kInvalidEntityId) { return false; } + const Ship* target = ships.findShip(id); + if (target) { return (target->position - ship.position).length() <= range; } + const Building* bld = buildings.findBuilding(id); + if (bld) { return (buildingCenter(*bld) - ship.position).length() <= range; } + return false; +} + +// --------------------------------------------------------------------------- +// tickHomeReturn (priority 4) +// --------------------------------------------------------------------------- + +void AiSystem::tickHomeReturn(ShipSystem& ships) +{ + ships.forEach([&](Ship& s) + { + if (!s.homeReturn) { return; } + if (s.hp / s.maxHp < s.homeReturn->retreatHpFraction) + { + if (4 > s.intent.priority) + { + s.intent = MovementIntent{4, s.homeReturn->homePos}; + } + } + }); +} + +// --------------------------------------------------------------------------- +// tickThreatResponse (priority 3) +// --------------------------------------------------------------------------- + +void AiSystem::tickThreatResponse(ShipSystem& ships, const BuildingSystem& buildings) +{ + const std::vector allBuildings = buildings.allBuildings(); + const std::vector allShips = ships.allShips(); + + ships.forEach([&](Ship& s) + { + if (!s.threatResponse) { return; } + + const float range = s.sensorRange; + + if (!s.isEnemy) + { + if (!isTargetValid(s.threatResponse->currentTarget.value_or(kInvalidEntityId), + range, s, ships, buildings)) + { + s.threatResponse->currentTarget = std::nullopt; + float bestDist = range; + for (const Ship& candidate : allShips) + { + if (!candidate.isEnemy) { continue; } + float dist = (candidate.position - s.position).length(); + if (dist < bestDist) + { + bestDist = dist; + s.threatResponse->currentTarget = candidate.id; + } + } + + for (const Building& b : allBuildings) + { + if (b.type != BuildingType::EnemyDefenceStation) { continue; } + float dist = (buildingCenter(b) - s.position).length(); + if (dist < bestDist) + { + bestDist = dist; + s.threatResponse->currentTarget = b.id; + } + } + } + + if (s.threatResponse->currentTarget) + { + QVector2D dest; + const Ship* tShip = ships.findShip(*s.threatResponse->currentTarget); + if (tShip) + { + dest = tShip->position; + } + else + { + const Building* tBld = buildings.findBuilding( + *s.threatResponse->currentTarget); + dest = tBld ? buildingCenter(*tBld) : s.position; + } + if (3 > s.intent.priority) + { + s.intent = MovementIntent{3, dest}; + } + } + else + { + if (3 > s.intent.priority) + { + if (s.rallyBehavior) + { + s.intent = MovementIntent{3, s.rallyBehavior->rallyPoint}; + } + else + { + s.intent = MovementIntent{3, QVector2D(s.position.x() + 1000.0f, + s.position.y())}; + } + } + } + } + else + { + if (!isTargetValid(s.threatResponse->currentTarget.value_or(kInvalidEntityId), + range, s, ships, buildings)) + { + s.threatResponse->currentTarget = std::nullopt; + float bestDist = range; + + for (const Ship& candidate : allShips) + { + if (candidate.isEnemy) { continue; } + float dist = (candidate.position - s.position).length(); + if (dist < bestDist) + { + bestDist = dist; + s.threatResponse->currentTarget = candidate.id; + } + } + + for (const Building& b : allBuildings) + { + if (b.type != BuildingType::PlayerDefenceStation + && b.type != BuildingType::Hq) + { + continue; + } + float dist = (buildingCenter(b) - s.position).length(); + if (dist < bestDist) + { + bestDist = dist; + s.threatResponse->currentTarget = b.id; + } + } + } + + if (s.threatResponse->currentTarget) + { + QVector2D dest; + const Ship* tShip = ships.findShip(*s.threatResponse->currentTarget); + if (tShip) + { + dest = tShip->position; + } + else + { + const Building* tBld = buildings.findBuilding( + *s.threatResponse->currentTarget); + dest = tBld ? buildingCenter(*tBld) : s.position; + } + if (3 > s.intent.priority) + { + s.intent = MovementIntent{3, dest}; + } + } + else + { + if (3 > s.intent.priority) + { + s.intent = MovementIntent{3, QVector2D(-10000.0f, s.position.y())}; + } + } + } + }); +} + +// --------------------------------------------------------------------------- +// tickRepairBehavior (priority 2) +// --------------------------------------------------------------------------- + +void AiSystem::tickRepairBehavior(ShipSystem& ships, BuildingSystem& buildings) +{ + const std::vector allBuildings = buildings.allBuildings(); + const std::vector allShips = ships.allShips(); + + ships.forEach([&](Ship& s) + { + if (!s.repairBehavior || !s.repairTool) { return; } + + const float repairRange = s.repairTool->range; + + bool enemyNearby = false; + for (const Ship& candidate : allShips) + { + if (candidate.isEnemy + && (candidate.position - s.position).length() <= s.sensorRange) + { + enemyNearby = true; + break; + } + } + if (enemyNearby) + { + if (2 > s.intent.priority) + { + s.intent = MovementIntent{2, QVector2D(-10000.0f, s.position.y())}; + } + return; + } + + EntityId currentId = s.repairBehavior->currentTarget.value_or(kInvalidEntityId); + bool targetValid = false; + if (currentId != kInvalidEntityId) + { + const Ship* tShip = ships.findShip(currentId); + if (tShip && !tShip->isEnemy && tShip->hp < tShip->maxHp) + { + targetValid = true; + } + else + { + const Building* tBld = buildings.findBuilding(currentId); + if (tBld && tBld->type == BuildingType::PlayerDefenceStation + && tBld->hp < tBld->maxHp) + { + targetValid = true; + } + } + } + + if (!targetValid) + { + s.repairBehavior->currentTarget = std::nullopt; + currentId = kInvalidEntityId; + float bestDist = s.sensorRange; + + for (const Ship& candidate : allShips) + { + if (candidate.isEnemy || candidate.id == s.id + || candidate.hp >= candidate.maxHp) + { + continue; + } + float dist = (candidate.position - s.position).length(); + if (dist < bestDist) + { + bestDist = dist; + s.repairBehavior->currentTarget = candidate.id; + } + } + + for (const Building& b : allBuildings) + { + if (b.type != BuildingType::PlayerDefenceStation + || b.hp >= b.maxHp) + { + continue; + } + float dist = (buildingCenter(b) - s.position).length(); + if (dist < bestDist) + { + bestDist = dist; + s.repairBehavior->currentTarget = b.id; + } + } + + currentId = s.repairBehavior->currentTarget.value_or(kInvalidEntityId); + } + + if (currentId == kInvalidEntityId) + { + if (2 > s.intent.priority) + { + s.intent = MovementIntent{2, QVector2D(s.position.x() + 1000.0f, + s.position.y())}; + } + return; + } + + QVector2D targetPos; + bool isShipTarget = false; + const Ship* tShip = ships.findShip(currentId); + if (tShip) + { + targetPos = tShip->position; + isShipTarget = true; + } + else + { + const Building* tBld = buildings.findBuilding(currentId); + targetPos = tBld ? buildingCenter(*tBld) : s.position; + } + + float distToTarget = (targetPos - s.position).length(); + if (distToTarget <= repairRange) + { + if (isShipTarget) + { + ships.healShip(currentId, s.repairTool->ratePerTick); + } + else + { + buildings.healBuilding(currentId, s.repairTool->ratePerTick); + } + } + + if (2 > s.intent.priority) + { + s.intent = MovementIntent{2, targetPos}; + } + }); +} + +// --------------------------------------------------------------------------- +// tickScrapCollector (priority 1) +// --------------------------------------------------------------------------- + +void AiSystem::tickScrapCollector(ShipSystem& ships, ScrapSystem& scraps, + BuildingSystem& buildings) +{ + const std::vector allShips = ships.allShips(); + + ships.forEach([&](Ship& s) + { + if (!s.scrapCollector || !s.cargo) { return; } + + const float collectRange = s.cargo->collectionRange; + + if (s.scrapCollector->deliveryBay == kInvalidEntityId) + { + const Building* bay = buildings.findNearestBuilding(s.position, + BuildingType::SalvageBay); + if (bay) + { + s.scrapCollector->deliveryBay = bay->id; + } + } + + const EntityId bayId = s.scrapCollector->deliveryBay; + + QVector2D bayPos = s.position; + if (bayId != kInvalidEntityId) + { + const Building* bay = buildings.findBuilding(bayId); + if (bay) + { + bayPos = buildingCenter(*bay); + } + } + + const bool cargoFull = (s.cargo->current >= s.cargo->capacity); + + if (cargoFull) + { + if (1 > s.intent.priority) + { + s.intent = MovementIntent{1, bayPos}; + } + if (bayId != kInvalidEntityId + && (s.position - bayPos).length() <= 1.0f) + { + if (buildings.deliverScrapToSalvageBay(bayId)) + { + --s.cargo->current; + } + } + return; + } + + bool retreating = false; + if (s.cargo->current == 0) + { + for (const Ship& candidate : allShips) + { + if (candidate.isEnemy + && (candidate.position - s.position).length() <= collectRange) + { + if (1 > s.intent.priority) + { + s.intent = MovementIntent{1, QVector2D(-10000.0f, s.position.y())}; + } + retreating = true; + break; + } + } + } + if (retreating) { return; } + + for (const Scrap& sc : scraps.allScraps()) + { + if ((sc.position - s.position).length() <= collectRange) + { + if (scraps.consume(sc.id)) + { + ++s.cargo->current; + s.scrapCollector->scrapTarget = std::nullopt; + } + break; + } + } + + if (s.scrapCollector->scrapTarget) + { + if (1 > s.intent.priority) + { + s.intent = MovementIntent{1, *s.scrapCollector->scrapTarget}; + } + } + else + { + float bestDist = s.sensorRange; + std::optional bestPos; + for (const Scrap& sc : scraps.allScraps()) + { + float dist = (sc.position - s.position).length(); + if (dist < bestDist) + { + bestDist = dist; + bestPos = sc.position; + } + } + if (bestPos) + { + s.scrapCollector->scrapTarget = bestPos; + if (1 > s.intent.priority) + { + s.intent = MovementIntent{1, *bestPos}; + } + } + else + { + if (1 > s.intent.priority) + { + s.intent = MovementIntent{1, QVector2D(s.position.x() + 1000.0f, + s.position.y())}; + } + } + } + }); +} diff --git a/src/lib/sim/AiSystem.h b/src/lib/sim/AiSystem.h new file mode 100644 index 0000000..dabfdcb --- /dev/null +++ b/src/lib/sim/AiSystem.h @@ -0,0 +1,14 @@ +#pragma once + +class BuildingSystem; +class ScrapSystem; +class ShipSystem; + +class AiSystem +{ +public: + void tickHomeReturn(ShipSystem& ships); + void tickThreatResponse(ShipSystem& ships, const BuildingSystem& buildings); + void tickRepairBehavior(ShipSystem& ships, BuildingSystem& buildings); + void tickScrapCollector(ShipSystem& ships, ScrapSystem& scraps, BuildingSystem& buildings); +}; diff --git a/src/lib/sim/CMakeLists.txt b/src/lib/sim/CMakeLists.txt index 7733af5..7c5b21e 100644 --- a/src/lib/sim/CMakeLists.txt +++ b/src/lib/sim/CMakeLists.txt @@ -10,6 +10,8 @@ SET(HDRS ${CMAKE_CURRENT_SOURCE_DIR}/ShipLayout.h ${CMAKE_CURRENT_SOURCE_DIR}/ShipLayoutBlueprint.h ${CMAKE_CURRENT_SOURCE_DIR}/ShipSystem.h + ${CMAKE_CURRENT_SOURCE_DIR}/AiSystem.h + ${CMAKE_CURRENT_SOURCE_DIR}/MovementSystem.h ${CMAKE_CURRENT_SOURCE_DIR}/ScrapSystem.h ${CMAKE_CURRENT_SOURCE_DIR}/WaveSystem.h ${CMAKE_CURRENT_SOURCE_DIR}/CombatSystem.h @@ -23,6 +25,8 @@ SET(SRCS ${CMAKE_CURRENT_SOURCE_DIR}/BeltSystem.cpp ${CMAKE_CURRENT_SOURCE_DIR}/BuildingSystem.cpp ${CMAKE_CURRENT_SOURCE_DIR}/ShipSystem.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/AiSystem.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/MovementSystem.cpp ${CMAKE_CURRENT_SOURCE_DIR}/ScrapSystem.cpp ${CMAKE_CURRENT_SOURCE_DIR}/WaveSystem.cpp ${CMAKE_CURRENT_SOURCE_DIR}/CombatSystem.cpp diff --git a/src/lib/sim/MovementSystem.cpp b/src/lib/sim/MovementSystem.cpp new file mode 100644 index 0000000..ec85c92 --- /dev/null +++ b/src/lib/sim/MovementSystem.cpp @@ -0,0 +1,102 @@ +#include "MovementSystem.h" + +#include +#include + +#include + +#include "Ship.h" +#include "ShipSystem.h" + +static float wrapAngle(float a) +{ + constexpr float kPi = 3.14159265f; + a = std::fmod(a, 2.0f * kPi); + if (a > kPi) { a -= 2.0f * kPi; } + if (a < -kPi) { a += 2.0f * kPi; } + return a; +} + +void MovementSystem::tick(ShipSystem& ships) +{ + ships.forEach([&](Ship& s) + { + if (s.intent.priority == 0) + { + s.velocity = QVector2D(0.0f, 0.0f); + s.rotationSpeed = 0.0f; + return; + } + + const QVector2D delta = s.intent.target - s.position; + const float dist = delta.length(); + + if (dist < 0.001f) + { + s.velocity = QVector2D(0.0f, 0.0f); + return; + } + + // ── Rotate toward target ────────────────────────────────────────── + const float desiredAngle = std::atan2(delta.y(), delta.x()); + const float angleDiff = wrapAngle(desiredAngle - s.facing); + + const float rotDelta = std::max(-s.angularAccelerationPerTick, + std::min(angleDiff, s.angularAccelerationPerTick)); + s.rotationSpeed += rotDelta; + s.rotationSpeed = std::max(-s.maxRotationSpeedPerTick, + std::min(s.rotationSpeed, s.maxRotationSpeedPerTick)); + + const bool sameSign = (s.rotationSpeed >= 0.0f) == (angleDiff >= 0.0f); + if (sameSign && std::abs(s.rotationSpeed) > std::abs(angleDiff)) + { + s.rotationSpeed = angleDiff; + } + + s.facing = wrapAngle(s.facing + s.rotationSpeed); + + // ── Desired velocity (with braking near target) ─────────────────── + const float manAccel = s.maneuveringAccelerationPerTick; + const float stoppingDist = (s.maxSpeedPerTick * s.maxSpeedPerTick) + / (2.0f * manAccel); + const float desiredSpeed = (dist <= stoppingDist) + ? std::sqrt(2.0f * manAccel * dist) + : s.maxSpeedPerTick; + const QVector2D desiredVel = delta.normalized() * desiredSpeed; + const QVector2D velError = desiredVel - s.velocity; + + // ── Main acceleration: forward only, along facing ───────────────── + const QVector2D facingVec(std::cos(s.facing), std::sin(s.facing)); + const float mainAligned = std::max(0.0f, + QVector2D::dotProduct(velError, facingVec)); + const float mainApplied = std::min(mainAligned, s.mainAccelerationPerTick); + const QVector2D mainDelta = facingVec * mainApplied; + + // ── Maneuvering acceleration: any direction, handles the remainder ─ + const QVector2D remaining = velError - mainDelta; + const float remainLen = remaining.length(); + const QVector2D maneuverDelta = (remainLen > manAccel) + ? remaining.normalized() * manAccel + : remaining; + + s.velocity += mainDelta + maneuverDelta; + + // ── Speed cap ───────────────────────────────────────────────────── + const float speed = s.velocity.length(); + if (speed > s.maxSpeedPerTick) + { + s.velocity = s.velocity.normalized() * s.maxSpeedPerTick; + } + + // ── Snap to target or advance ───────────────────────────────────── + if (dist <= s.velocity.length()) + { + s.position = s.intent.target; + s.velocity = QVector2D(0.0f, 0.0f); + } + else + { + s.position += s.velocity; + } + }); +} diff --git a/src/lib/sim/MovementSystem.h b/src/lib/sim/MovementSystem.h new file mode 100644 index 0000000..60969b4 --- /dev/null +++ b/src/lib/sim/MovementSystem.h @@ -0,0 +1,9 @@ +#pragma once + +class ShipSystem; + +class MovementSystem +{ +public: + void tick(ShipSystem& ships); +}; diff --git a/src/lib/sim/ShipSystem.cpp b/src/lib/sim/ShipSystem.cpp index 92496ad..f87101c 100644 --- a/src/lib/sim/ShipSystem.cpp +++ b/src/lib/sim/ShipSystem.cpp @@ -2,17 +2,10 @@ #include #include -#include -#include #include #include -#include "Building.h" -#include "BuildingSystem.h" -#include "BuildingType.h" #include "ModulesConfig.h" -#include "Scrap.h" -#include "ScrapSystem.h" #include "Tick.h" ShipSystem::ShipSystem(const GameConfig& config, @@ -217,38 +210,6 @@ void ShipSystem::forEach(std::function fn) } } -// --------------------------------------------------------------------------- -// Private helpers -// --------------------------------------------------------------------------- - -static QVector2D buildingCenter(const Building& b) -{ - return QVector2D(b.anchor.x() + b.footprint.width() / 2.0f, - b.anchor.y() + b.footprint.height() / 2.0f); -} - -bool ShipSystem::isTargetValid(EntityId id, float range, const Ship& ship, - const BuildingSystem& buildings) const -{ - if (id == kInvalidEntityId) - { - return false; - } - // Check ship pool first. - const Ship* target = findShip(id); - if (target) - { - return (target->position - ship.position).length() <= range; - } - // Check building pool (HQ and defence stations are targetable). - const Building* bld = buildings.findBuilding(id); - if (bld) - { - return (buildingCenter(*bld) - ship.position).length() <= range; - } - return false; -} - bool ShipSystem::healShip(EntityId id, float amount) { for (Ship& s : m_ships) @@ -287,474 +248,6 @@ void ShipSystem::clearMovementIntents() } } -// --------------------------------------------------------------------------- -// tickHomeReturn (priority 4) -// --------------------------------------------------------------------------- - -void ShipSystem::tickHomeReturn() -{ - for (Ship& s : m_ships) - { - if (!s.homeReturn) - { - continue; - } - if (s.hp / s.maxHp < s.homeReturn->retreatHpFraction) - { - if (4 > s.intent.priority) - { - s.intent = MovementIntent{4, s.homeReturn->homePos}; - } - } - } -} - -// --------------------------------------------------------------------------- -// tickThreatResponse (priority 3) -// --------------------------------------------------------------------------- - -void ShipSystem::tickThreatResponse(const BuildingSystem& buildings) -{ - // Snapshot all buildings once (used for enemy targeting). - const std::vector allBuildings = buildings.allBuildings(); - - for (Ship& s : m_ships) - { - if (!s.threatResponse) - { - continue; - } - - const float range = s.sensorRange; - - if (!s.isEnemy) - { - // Player combat ship: target nearest enemy ship or enemy building. - if (!isTargetValid(s.threatResponse->currentTarget.value_or(kInvalidEntityId), - range, s, buildings)) - { - s.threatResponse->currentTarget = std::nullopt; - float bestDist = range; - for (const Ship& candidate : m_ships) - { - if (!candidate.isEnemy) - { - continue; - } - float dist = (candidate.position - s.position).length(); - if (dist < bestDist) - { - bestDist = dist; - s.threatResponse->currentTarget = candidate.id; - } - } - - for (const Building& b : allBuildings) - { - if (b.type != BuildingType::EnemyDefenceStation) - { - continue; - } - float dist = (buildingCenter(b) - s.position).length(); - if (dist < bestDist) - { - bestDist = dist; - s.threatResponse->currentTarget = b.id; - } - } - } - - if (s.threatResponse->currentTarget) - { - QVector2D dest; - const Ship* tShip = findShip(*s.threatResponse->currentTarget); - if (tShip) - { - dest = tShip->position; - } - else - { - const Building* tBld = buildings.findBuilding( - *s.threatResponse->currentTarget); - dest = tBld ? buildingCenter(*tBld) : s.position; - } - if (3 > s.intent.priority) - { - s.intent = MovementIntent{3, dest}; - } - } - else - { - // No target: gather at rally point or patrol rightward once departed. - if (3 > s.intent.priority) - { - if (s.rallyBehavior) - { - s.intent = MovementIntent{3, s.rallyBehavior->rallyPoint}; - } - else - { - s.intent = MovementIntent{3, QVector2D(s.position.x() + 1000.0f, - s.position.y())}; - } - } - } - } - else - { - // Enemy ship: target nearest player ship or player building. - if (!isTargetValid(s.threatResponse->currentTarget.value_or(kInvalidEntityId), - range, s, buildings)) - { - s.threatResponse->currentTarget = std::nullopt; - float bestDist = range; - - for (const Ship& candidate : m_ships) - { - if (candidate.isEnemy) - { - continue; - } - float dist = (candidate.position - s.position).length(); - if (dist < bestDist) - { - bestDist = dist; - s.threatResponse->currentTarget = candidate.id; - } - } - - for (const Building& b : allBuildings) - { - if (b.type != BuildingType::PlayerDefenceStation - && b.type != BuildingType::Hq) - { - continue; - } - float dist = (buildingCenter(b) - s.position).length(); - if (dist < bestDist) - { - bestDist = dist; - s.threatResponse->currentTarget = b.id; - } - } - } - - if (s.threatResponse->currentTarget) - { - // Move toward target (building or ship). - QVector2D dest; - const Ship* tShip = findShip(*s.threatResponse->currentTarget); - if (tShip) - { - dest = tShip->position; - } - else - { - const Building* tBld = buildings.findBuilding( - *s.threatResponse->currentTarget); - dest = tBld ? buildingCenter(*tBld) : s.position; - } - if (3 > s.intent.priority) - { - s.intent = MovementIntent{3, dest}; - } - } - else - { - // No target: move toward asteroid (leftward). - if (3 > s.intent.priority) - { - s.intent = MovementIntent{3, QVector2D(-10000.0f, s.position.y())}; - } - } - } - } -} - -// --------------------------------------------------------------------------- -// tickRepairBehavior (priority 2) -// --------------------------------------------------------------------------- - -void ShipSystem::tickRepairBehavior(BuildingSystem& buildings) -{ - const std::vector allBuildings = buildings.allBuildings(); - - for (Ship& s : m_ships) - { - if (!s.repairBehavior || !s.repairTool) - { - continue; - } - - const float repairRange = s.repairTool->range; - - // Check for nearby enemies; if present, retreat. - bool enemyNearby = false; - for (const Ship& candidate : m_ships) - { - if (candidate.isEnemy - && (candidate.position - s.position).length() <= s.sensorRange) - { - enemyNearby = true; - break; - } - } - if (enemyNearby) - { - if (2 > s.intent.priority) - { - s.intent = MovementIntent{2, QVector2D(-10000.0f, s.position.y())}; - } - continue; - } - - // Validate current repair target. - EntityId currentId = s.repairBehavior->currentTarget.value_or(kInvalidEntityId); - bool targetValid = false; - if (currentId != kInvalidEntityId) - { - const Ship* tShip = findShip(currentId); - if (tShip && !tShip->isEnemy && tShip->hp < tShip->maxHp) - { - targetValid = true; - } - else - { - const Building* tBld = buildings.findBuilding(currentId); - if (tBld && tBld->type == BuildingType::PlayerDefenceStation - && tBld->hp < tBld->maxHp) - { - targetValid = true; - } - } - } - - if (!targetValid) - { - s.repairBehavior->currentTarget = std::nullopt; - currentId = kInvalidEntityId; - float bestDist = s.sensorRange; - - for (const Ship& candidate : m_ships) - { - if (candidate.isEnemy || candidate.id == s.id - || candidate.hp >= candidate.maxHp) - { - continue; - } - float dist = (candidate.position - s.position).length(); - if (dist < bestDist) - { - bestDist = dist; - s.repairBehavior->currentTarget = candidate.id; - } - } - - for (const Building& b : allBuildings) - { - if (b.type != BuildingType::PlayerDefenceStation - || b.hp >= b.maxHp) - { - continue; - } - float dist = (buildingCenter(b) - s.position).length(); - if (dist < bestDist) - { - bestDist = dist; - s.repairBehavior->currentTarget = b.id; - } - } - - currentId = s.repairBehavior->currentTarget.value_or(kInvalidEntityId); - } - - if (currentId == kInvalidEntityId) - { - // No target: patrol rightward. - if (2 > s.intent.priority) - { - s.intent = MovementIntent{2, QVector2D(s.position.x() + 1000.0f, - s.position.y())}; - } - continue; - } - - // Compute target position and whether we are in repair range. - QVector2D targetPos; - bool isShipTarget = false; - const Ship* tShip = findShip(currentId); - if (tShip) - { - targetPos = tShip->position; - isShipTarget = true; - } - else - { - const Building* tBld = buildings.findBuilding(currentId); - targetPos = tBld ? buildingCenter(*tBld) : s.position; - } - - float distToTarget = (targetPos - s.position).length(); - if (distToTarget <= repairRange) - { - if (isShipTarget) - { - healShip(currentId, s.repairTool->ratePerTick); - } - else - { - buildings.healBuilding(currentId, s.repairTool->ratePerTick); - } - } - - if (2 > s.intent.priority) - { - s.intent = MovementIntent{2, targetPos}; - } - } -} - -// --------------------------------------------------------------------------- -// tickScrapCollector (priority 1) -// --------------------------------------------------------------------------- - -void ShipSystem::tickScrapCollector(ScrapSystem& scraps, const BuildingSystem& buildings) -{ - for (Ship& s : m_ships) - { - if (!s.scrapCollector || !s.cargo) - { - continue; - } - - const float collectRange = s.cargo->collectionRange; - - // Assign delivery bay if not yet set. - if (s.scrapCollector->deliveryBay == kInvalidEntityId) - { - const Building* bay = buildings.findNearestBuilding(s.position, - BuildingType::SalvageBay); - if (bay) - { - s.scrapCollector->deliveryBay = bay->id; - } - } - - const EntityId bayId = s.scrapCollector->deliveryBay; - - // Compute bay position for movement. - QVector2D bayPos = s.position; - if (bayId != kInvalidEntityId) - { - const Building* bay = buildings.findBuilding(bayId); - if (bay) - { - bayPos = buildingCenter(*bay); - } - } - - const bool cargoFull = (s.cargo->current >= s.cargo->capacity); - - if (cargoFull) - { - // Return to bay and deliver. - if (1 > s.intent.priority) - { - s.intent = MovementIntent{1, bayPos}; - } - if (bayId != kInvalidEntityId - && (s.position - bayPos).length() <= 1.0f) - { - // Deliver one item per tick. - if (const_cast(buildings).deliverScrapToSalvageBay(bayId)) - { - --s.cargo->current; - } - } - continue; - } - - // Retreat from enemies when not carrying cargo. - bool retreating = false; - if (s.cargo->current == 0) - { - for (const Ship& candidate : m_ships) - { - if (candidate.isEnemy - && (candidate.position - s.position).length() <= collectRange) - { - if (1 > s.intent.priority) - { - s.intent = MovementIntent{1, - QVector2D(-10000.0f, s.position.y())}; - } - retreating = true; - break; - } - } - } - if (retreating) - { - continue; - } - - // Collect scrap if within range. - for (const Scrap& sc : scraps.allScraps()) - { - if ((sc.position - s.position).length() <= collectRange) - { - if (scraps.consume(sc.id)) - { - ++s.cargo->current; - s.scrapCollector->scrapTarget = std::nullopt; - } - break; - } - } - - if (s.scrapCollector->scrapTarget) - { - // Move toward known scrap target. - if (1 > s.intent.priority) - { - s.intent = MovementIntent{1, *s.scrapCollector->scrapTarget}; - } - } - else - { - // Scan for nearest scrap within sensor range. - float bestDist = s.sensorRange; - std::optional bestPos; - for (const Scrap& sc : scraps.allScraps()) - { - float dist = (sc.position - s.position).length(); - if (dist < bestDist) - { - bestDist = dist; - bestPos = sc.position; - } - } - if (bestPos) - { - s.scrapCollector->scrapTarget = bestPos; - if (1 > s.intent.priority) - { - s.intent = MovementIntent{1, *bestPos}; - } - } - else - { - // No scrap in range: patrol rightward. - if (1 > s.intent.priority) - { - s.intent = MovementIntent{1, QVector2D(s.position.x() + 1000.0f, - s.position.y())}; - } - } - } - } -} - // --------------------------------------------------------------------------- // Rally point management (REQ-SHP-RALLY) // --------------------------------------------------------------------------- @@ -774,104 +267,3 @@ void ShipSystem::triggerRallyDeparture() } } } - -// --------------------------------------------------------------------------- -// tickMovement (tick-order step 10) -// --------------------------------------------------------------------------- - -// Reduces angle to [-π, π]. -static float wrapAngle(float a) -{ - constexpr float kPi = 3.14159265f; - a = std::fmod(a, 2.0f * kPi); - if (a > kPi) { a -= 2.0f * kPi; } - if (a < -kPi) { a += 2.0f * kPi; } - return a; -} - -void ShipSystem::tickMovement() -{ - for (Ship& s : m_ships) - { - if (s.intent.priority == 0) - { - s.velocity = QVector2D(0.0f, 0.0f); - s.rotationSpeed = 0.0f; - continue; - } - - const QVector2D delta = s.intent.target - s.position; - const float dist = delta.length(); - - if (dist < 0.001f) - { - s.velocity = QVector2D(0.0f, 0.0f); - continue; - } - - // ── Rotate toward target ────────────────────────────────────────── - const float desiredAngle = std::atan2(delta.y(), delta.x()); - const float angleDiff = wrapAngle(desiredAngle - s.facing); - - // Clamp angular acceleration, accumulate rotation speed. - const float rotDelta = std::max(-s.angularAccelerationPerTick, - std::min(angleDiff, s.angularAccelerationPerTick)); - s.rotationSpeed += rotDelta; - s.rotationSpeed = std::max(-s.maxRotationSpeedPerTick, - std::min(s.rotationSpeed, s.maxRotationSpeedPerTick)); - - // Prevent overshooting the desired angle this tick. - const bool sameSign = (s.rotationSpeed >= 0.0f) == (angleDiff >= 0.0f); - if (sameSign && std::abs(s.rotationSpeed) > std::abs(angleDiff)) - { - s.rotationSpeed = angleDiff; - } - - s.facing = wrapAngle(s.facing + s.rotationSpeed); - - // ── Desired velocity (with braking near target) ─────────────────── - // Stopping distance using maneuvering acceleration as the worst-case brake. - const float manAccel = s.maneuveringAccelerationPerTick; - const float stoppingDist = (s.maxSpeedPerTick * s.maxSpeedPerTick) - / (2.0f * manAccel); - const float desiredSpeed = (dist <= stoppingDist) - ? std::sqrt(2.0f * manAccel * dist) - : s.maxSpeedPerTick; - const QVector2D desiredVel = delta.normalized() * desiredSpeed; - const QVector2D velError = desiredVel - s.velocity; - - // ── Main acceleration: forward only, along facing ───────────────── - const QVector2D facingVec(std::cos(s.facing), std::sin(s.facing)); - const float mainAligned = std::max(0.0f, - QVector2D::dotProduct(velError, facingVec)); - const float mainApplied = std::min(mainAligned, s.mainAccelerationPerTick); - const QVector2D mainDelta = facingVec * mainApplied; - - // ── Maneuvering acceleration: any direction, handles the remainder ─ - const QVector2D remaining = velError - mainDelta; - const float remainLen = remaining.length(); - const QVector2D maneuverDelta = (remainLen > manAccel) - ? remaining.normalized() * manAccel - : remaining; - - s.velocity += mainDelta + maneuverDelta; - - // ── Speed cap ───────────────────────────────────────────────────── - const float speed = s.velocity.length(); - if (speed > s.maxSpeedPerTick) - { - s.velocity = s.velocity.normalized() * s.maxSpeedPerTick; - } - - // ── Snap to target or advance ───────────────────────────────────── - if (dist <= s.velocity.length()) - { - s.position = s.intent.target; - s.velocity = QVector2D(0.0f, 0.0f); - } - else - { - s.position += s.velocity; - } - } -} diff --git a/src/lib/sim/ShipSystem.h b/src/lib/sim/ShipSystem.h index d6473b8..11f83e4 100644 --- a/src/lib/sim/ShipSystem.h +++ b/src/lib/sim/ShipSystem.h @@ -10,9 +10,6 @@ #include "Ship.h" #include "ShipLayout.h" -class BuildingSystem; -class ScrapSystem; - class ShipSystem { public: @@ -29,26 +26,9 @@ public: std::vector allShips() const; void forEach(std::function fn); - // -- Behavior tick methods (tick-order step 7) --------------------------- - // Reset all movement intents to priority 0 before behavior systems run. void clearMovementIntents(); - // Priority 4: low-HP ships retreat to homePos. - void tickHomeReturn(); - - // Priority 3: combat ships acquire targets and advance toward them. - void tickThreatResponse(const BuildingSystem& buildings); - - // Priority 2: repair ships find and heal damaged friendly ships/stations. - void tickRepairBehavior(BuildingSystem& buildings); - - // Priority 1: salvage ships collect scrap and deliver it. - void tickScrapCollector(ScrapSystem& scraps, const BuildingSystem& buildings); - - // -- Movement (tick-order step 10) --------------------------------------- - void tickMovement(); - // Set the rally point that newly spawned player combat ships will loiter at. void setRallyPoint(QVector2D point); @@ -59,19 +39,14 @@ public: // Returns false if ship not found. bool damageShip(EntityId id, float amount); -private: - const ShipDef* findShipDef(const std::string& schematicId) const; - const ModuleDef* findModuleDef(const std::string& id) const; - - // True if the entity identified by id is alive and within range of ship. - // Searches both the ship list and (for buildings) the supplied BuildingSystem. - bool isTargetValid(EntityId id, float range, const Ship& ship, - const BuildingSystem& buildings) const; - // Heal the ship with the given id by amount, clamped to maxHp. // Returns false if the ship is not found. bool healShip(EntityId id, float amount); +private: + const ShipDef* findShipDef(const std::string& schematicId) const; + const ModuleDef* findModuleDef(const std::string& id) const; + const GameConfig& m_config; std::function m_allocateId; std::vector m_ships; diff --git a/src/lib/sim/Simulation.cpp b/src/lib/sim/Simulation.cpp index a92f3b4..aa795a8 100644 --- a/src/lib/sim/Simulation.cpp +++ b/src/lib/sim/Simulation.cpp @@ -2,8 +2,10 @@ #include +#include "AiSystem.h" #include "BuildingSystem.h" #include "CombatSystem.h" +#include "MovementSystem.h" #include "ScrapSystem.h" #include "ShipSystem.h" #include "SurfaceMask.h" @@ -41,10 +43,12 @@ Simulation::Simulation(GameConfig config, unsigned int seed) m_shipSystem->spawn(id, it->second.level, pos, /*isEnemy=*/false, layout); }, m_rng); - m_shipSystem = std::make_unique(m_config, [this]() { return allocateId(); }); - m_scrapSystem = std::make_unique([this]() { return allocateId(); }); - m_waveSystem = std::make_unique(m_config, m_rng); - m_combatSystem = std::make_unique(m_config); + m_shipSystem = std::make_unique(m_config, [this]() { return allocateId(); }); + m_aiSystem = std::make_unique(); + m_movementSystem = std::make_unique(); + m_scrapSystem = std::make_unique([this]() { return allocateId(); }); + m_waveSystem = std::make_unique(m_config, m_rng); + m_combatSystem = std::make_unique(m_config); // Initialize schematic unlock state. for (const ShipDef& def : m_config.ships.ships) @@ -104,10 +108,12 @@ void Simulation::reset(unsigned int seed) m_shipSystem->spawn(id, it->second.level, pos, /*isEnemy=*/false, layout); }, m_rng); - m_shipSystem = std::make_unique(m_config, [this]() { return allocateId(); }); - m_scrapSystem = std::make_unique([this]() { return allocateId(); }); - m_waveSystem = std::make_unique(m_config, m_rng); - m_combatSystem = std::make_unique(m_config); + m_shipSystem = std::make_unique(m_config, [this]() { return allocateId(); }); + m_aiSystem = std::make_unique(); + m_movementSystem = std::make_unique(); + m_scrapSystem = std::make_unique([this]() { return allocateId(); }); + m_waveSystem = std::make_unique(m_config, m_rng); + m_combatSystem = std::make_unique(m_config); m_schematicLevels.clear(); for (const ShipDef& def : m_config.ships.ships) @@ -152,10 +158,10 @@ void Simulation::tick() } m_shipSystem->clearMovementIntents(); - m_shipSystem->tickHomeReturn(); // priority 4 - m_shipSystem->tickThreatResponse(*m_buildingSystem); // priority 3 - m_shipSystem->tickRepairBehavior(*m_buildingSystem); // priority 2 - m_shipSystem->tickScrapCollector(*m_scrapSystem, *m_buildingSystem); // priority 1 + m_aiSystem->tickHomeReturn(*m_shipSystem); // priority 4 + m_aiSystem->tickThreatResponse(*m_shipSystem, *m_buildingSystem); // priority 3 + m_aiSystem->tickRepairBehavior(*m_shipSystem, *m_buildingSystem); // priority 2 + m_aiSystem->tickScrapCollector(*m_shipSystem, *m_scrapSystem, *m_buildingSystem); // priority 1 // Step 8: combat resolution m_combatSystem->tick(m_currentTick, *m_shipSystem, @@ -171,7 +177,7 @@ void Simulation::tick() } // Step 10: advance ship positions - m_shipSystem->tickMovement(); + m_movementSystem->tick(*m_shipSystem); // Step 11: scrap despawn m_scrapSystem->tickDespawn(m_currentTick); diff --git a/src/lib/sim/Simulation.h b/src/lib/sim/Simulation.h index ae9e33a..f7745d2 100644 --- a/src/lib/sim/Simulation.h +++ b/src/lib/sim/Simulation.h @@ -17,8 +17,10 @@ #include "Rotation.h" #include "Tick.h" +class AiSystem; class BuildingSystem; class CombatSystem; +class MovementSystem; class ShipSystem; class ScrapSystem; class WaveSystem; @@ -111,12 +113,14 @@ private: }; std::map m_schematicLevels; - BeltSystem m_beltSystem; - std::unique_ptr m_buildingSystem; - std::unique_ptr m_shipSystem; - std::unique_ptr m_scrapSystem; - std::unique_ptr m_waveSystem; - std::unique_ptr m_combatSystem; + BeltSystem m_beltSystem; + std::unique_ptr m_buildingSystem; + std::unique_ptr m_shipSystem; + std::unique_ptr m_aiSystem; + std::unique_ptr m_movementSystem; + std::unique_ptr m_scrapSystem; + std::unique_ptr m_waveSystem; + std::unique_ptr m_combatSystem; std::vector m_fireEvents; std::vector m_schematicDropEvents; diff --git a/src/test/BehaviorSystemTest.cpp b/src/test/BehaviorSystemTest.cpp index 6738bc5..8153ccd 100644 --- a/src/test/BehaviorSystemTest.cpp +++ b/src/test/BehaviorSystemTest.cpp @@ -4,11 +4,13 @@ #include +#include "AiSystem.h" #include "BeltSystem.h" #include "Building.h" #include "BuildingSystem.h" #include "BuildingType.h" #include "ConfigLoader.h" +#include "MovementSystem.h" #include "Rotation.h" #include "Scrap.h" #include "ScrapSystem.h" @@ -27,15 +29,17 @@ static GameConfig loadConfig() struct Fixture { - GameConfig cfg; - BeltSystem belts; - EntityId nextId; - int stock; - std::mt19937 rng; + GameConfig cfg; + BeltSystem belts; + EntityId nextId; + int stock; + std::mt19937 rng; BuildingSystem buildings; - ShipSystem ships; - ScrapSystem scraps; - Tick tick; + ShipSystem ships; + AiSystem ai; + MovementSystem movement; + ScrapSystem scraps; + Tick tick; explicit Fixture() : cfg(loadConfig()) @@ -58,11 +62,11 @@ struct Fixture void runBehaviorTick() { ships.clearMovementIntents(); - ships.tickHomeReturn(); - ships.tickThreatResponse(buildings); - ships.tickRepairBehavior(buildings); - ships.tickScrapCollector(scraps, buildings); - ships.tickMovement(); + ai.tickHomeReturn(ships); + ai.tickThreatResponse(ships, buildings); + ai.tickRepairBehavior(ships, buildings); + ai.tickScrapCollector(ships, scraps, buildings); + movement.tick(ships); ++tick; } }; @@ -107,7 +111,7 @@ TEST_CASE("BehaviorSystem: tickMovement advances ship by maxSpeedPerTick toward f.ships.forEach([&target](Ship& s) { s.intent = MovementIntent{1, target}; }); - f.ships.tickMovement(); + f.movement.tick(f.ships); const Ship* s = f.ships.findShip(id); REQUIRE(s->position.x() == Approx(speed)); @@ -129,7 +133,7 @@ TEST_CASE("BehaviorSystem: tickMovement stops exactly at target without overshoo f.ships.forEach([&target](Ship& s) { s.intent = MovementIntent{1, target}; }); - f.ships.tickMovement(); + f.movement.tick(f.ships); const Ship* s = f.ships.findShip(id); REQUIRE(s->position.x() == Approx(target.x())); @@ -152,7 +156,7 @@ TEST_CASE("BehaviorSystem: tickHomeReturn does nothing when HP is above threshol }); f.ships.clearMovementIntents(); - f.ships.tickHomeReturn(); + f.ai.tickHomeReturn(f.ships); REQUIRE(f.ships.findShip(id)->intent.priority == 0); } @@ -170,7 +174,7 @@ TEST_CASE("BehaviorSystem: tickHomeReturn writes priority-4 intent toward homePo }); f.ships.clearMovementIntents(); - f.ships.tickHomeReturn(); + f.ai.tickHomeReturn(f.ships); const Ship* s = f.ships.findShip(id); REQUIRE(s->intent.priority == 4); @@ -195,8 +199,8 @@ TEST_CASE("BehaviorSystem: tickHomeReturn priority-4 beats tickThreatResponse pr }); f.ships.clearMovementIntents(); - f.ships.tickHomeReturn(); - f.ships.tickThreatResponse(f.buildings); + f.ai.tickHomeReturn(f.ships); + f.ai.tickThreatResponse(f.ships, f.buildings); const Ship* s = f.ships.findShip(playerId); REQUIRE(s->intent.priority == 4); @@ -217,7 +221,7 @@ TEST_CASE("BehaviorSystem: player combat ship acquires nearest enemy ship in ran /*isEnemy=*/true); f.ships.clearMovementIntents(); - f.ships.tickThreatResponse(f.buildings); + f.ai.tickThreatResponse(f.ships, f.buildings); const Ship* player = f.ships.findShip(playerId); REQUIRE(player->threatResponse.has_value()); @@ -233,7 +237,7 @@ TEST_CASE("BehaviorSystem: player combat ship does not target friendly ships", f.ships.spawn("interceptor", 1, QVector2D(5.0f, 0.0f)); // also player (isEnemy=false) f.ships.clearMovementIntents(); - f.ships.tickThreatResponse(f.buildings); + f.ai.tickThreatResponse(f.ships, f.buildings); const Ship* s = f.ships.findShip(id1); REQUIRE(s->threatResponse.has_value()); @@ -249,7 +253,7 @@ TEST_CASE("BehaviorSystem: player combat ship ignores enemy beyond engagement ra f.ships.spawn("interceptor", 1, QVector2D(500.0f, 0.0f), /*isEnemy=*/true); f.ships.clearMovementIntents(); - f.ships.tickThreatResponse(f.buildings); + f.ai.tickThreatResponse(f.ships, f.buildings); const Ship* s = f.ships.findShip(playerId); REQUIRE_FALSE(s->threatResponse->currentTarget.has_value()); @@ -268,7 +272,7 @@ TEST_CASE("BehaviorSystem: enemy ship acquires nearest player ship in range", /*isEnemy=*/true); f.ships.clearMovementIntents(); - f.ships.tickThreatResponse(f.buildings); + f.ai.tickThreatResponse(f.ships, f.buildings); const Ship* enemy = f.ships.findShip(enemyId); REQUIRE(enemy->threatResponse.has_value()); @@ -284,7 +288,7 @@ TEST_CASE("BehaviorSystem: enemy ship with no target writes leftward movement in /*isEnemy=*/true); f.ships.clearMovementIntents(); - f.ships.tickThreatResponse(f.buildings); + f.ai.tickThreatResponse(f.ships, f.buildings); const Ship* enemy = f.ships.findShip(enemyId); REQUIRE(enemy->intent.priority == 3); @@ -311,7 +315,7 @@ TEST_CASE("BehaviorSystem: repair ship writes intent toward damaged friendly shi }); f.ships.clearMovementIntents(); - f.ships.tickRepairBehavior(f.buildings); + f.ai.tickRepairBehavior(f.ships, f.buildings); const Ship* repair = f.ships.findShip(repairId); REQUIRE(repair->intent.priority == 2); @@ -335,7 +339,7 @@ TEST_CASE("BehaviorSystem: repair ship heals damaged ally within repair range", }); f.ships.clearMovementIntents(); - f.ships.tickRepairBehavior(f.buildings); + f.ai.tickRepairBehavior(f.ships, f.buildings); // repair_rate_formula = "5 + x" at x=1 → 6; hp should have increased. const Ship* friendly = f.ships.findShip(friendlyId); @@ -359,7 +363,7 @@ TEST_CASE("BehaviorSystem: repair ship does not heal above maxHp", "[behavior]") for (int i = 0; i < 5; ++i) { f.ships.clearMovementIntents(); - f.ships.tickRepairBehavior(f.buildings); + f.ai.tickRepairBehavior(f.ships, f.buildings); } const Ship* friendly = f.ships.findShip(friendlyId); @@ -382,7 +386,7 @@ TEST_CASE("BehaviorSystem: salvage ship writes intent toward nearest scrap", "[b f.scraps.spawn(scrapPos, 1, farFuture); f.ships.clearMovementIntents(); - f.ships.tickScrapCollector(f.scraps, f.buildings); + f.ai.tickScrapCollector(f.ships, f.scraps, f.buildings); const Ship* s = f.ships.findShip(shipId); REQUIRE(s->intent.priority == 1); @@ -398,7 +402,7 @@ TEST_CASE("BehaviorSystem: salvage ship collects scrap on arrival", "[behavior]" const EntityId scrapId = f.scraps.spawn(QVector2D(0.0f, 0.0f), 1, farFuture); f.ships.clearMovementIntents(); - f.ships.tickScrapCollector(f.scraps, f.buildings); + f.ai.tickScrapCollector(f.ships, f.scraps, f.buildings); const Ship* s = f.ships.findShip(shipId); REQUIRE(s->cargo->current == 1); @@ -436,7 +440,7 @@ TEST_CASE("BehaviorSystem: full-cargo salvage ship moves toward SalvageBay", "[b }); f.ships.clearMovementIntents(); - f.ships.tickScrapCollector(f.scraps, f.buildings); + f.ai.tickScrapCollector(f.ships, f.scraps, f.buildings); // Intent should point toward the bay (x < 0 area), not rightward. const Ship* s = f.ships.findShip(shipId); @@ -469,7 +473,7 @@ TEST_CASE("SensorRange: player combat ship acquires enemy just inside sensor ran /*isEnemy=*/true); f.ships.clearMovementIntents(); - f.ships.tickThreatResponse(f.buildings); + f.ai.tickThreatResponse(f.ships, f.buildings); const Ship* player = f.ships.findShip(playerId); REQUIRE(player->threatResponse->currentTarget == enemyId); @@ -483,7 +487,7 @@ TEST_CASE("SensorRange: player combat ship ignores enemy just outside sensor ran f.ships.spawn("interceptor", 1, QVector2D(210.0f, 0.0f), /*isEnemy=*/true); f.ships.clearMovementIntents(); - f.ships.tickThreatResponse(f.buildings); + f.ai.tickThreatResponse(f.ships, f.buildings); const Ship* player = f.ships.findShip(playerId); REQUIRE_FALSE(player->threatResponse->currentTarget.has_value()); @@ -498,7 +502,7 @@ TEST_CASE("SensorRange: enemy ship ignores player just outside sensor range", "[ /*isEnemy=*/true); f.ships.clearMovementIntents(); - f.ships.tickThreatResponse(f.buildings); + f.ai.tickThreatResponse(f.ships, f.buildings); const Ship* enemy = f.ships.findShip(enemyId); REQUIRE_FALSE(enemy->threatResponse->currentTarget.has_value()); @@ -516,7 +520,7 @@ TEST_CASE("SensorRange: repair ship retreats from enemy within sensor range", "[ f.ships.spawn("interceptor", 1, QVector2D(200.0f, 0.0f), /*isEnemy=*/true); f.ships.clearMovementIntents(); - f.ships.tickRepairBehavior(f.buildings); + f.ai.tickRepairBehavior(f.ships, f.buildings); const Ship* repair = f.ships.findShip(repairId); REQUIRE(repair->intent.priority == 2); @@ -531,7 +535,7 @@ TEST_CASE("SensorRange: repair ship does not retreat from enemy beyond sensor ra f.ships.spawn("interceptor", 1, QVector2D(300.0f, 0.0f), /*isEnemy=*/true); f.ships.clearMovementIntents(); - f.ships.tickRepairBehavior(f.buildings); + f.ai.tickRepairBehavior(f.ships, f.buildings); // Enemy outside sensor range → repair ship patrols rightward instead of retreating. const Ship* repair = f.ships.findShip(repairId); @@ -549,7 +553,7 @@ TEST_CASE("SensorRange: repair ship does not acquire damaged ally beyond sensor }); f.ships.clearMovementIntents(); - f.ships.tickRepairBehavior(f.buildings); + f.ai.tickRepairBehavior(f.ships, f.buildings); REQUIRE_FALSE(f.ships.findShip(repairId)->repairBehavior->currentTarget.has_value()); } @@ -566,7 +570,7 @@ TEST_CASE("SensorRange: salvage ship ignores scrap beyond sensor range", "[senso f.scraps.spawn(QVector2D(300.0f, 0.0f), 1, 100000); f.ships.clearMovementIntents(); - f.ships.tickScrapCollector(f.scraps, f.buildings); + f.ai.tickScrapCollector(f.ships, f.scraps, f.buildings); const Ship* s = f.ships.findShip(shipId); REQUIRE(s->scrapCollector->scrapTarget == std::nullopt);