From 65de4ddc5c3d44ba02c182ecf64356cbef3b2b95 Mon Sep 17 00:00:00 2001 From: mlangkabel Date: Mon, 20 Apr 2026 08:29:53 +0200 Subject: [PATCH] implement ship behaviors --- docs/plan.md | 2 +- src/lib/sim/BuildingSystem.cpp | 59 ++++ src/lib/sim/BuildingSystem.h | 11 + src/lib/sim/ScrapSystem.cpp | 15 + src/lib/sim/ScrapSystem.h | 6 +- src/lib/sim/Ship.h | 7 +- src/lib/sim/ShipSystem.cpp | 538 +++++++++++++++++++++++++++++++- src/lib/sim/ShipSystem.h | 36 ++- src/lib/sim/Simulation.cpp | 23 +- src/test/BehaviorSystemTest.cpp | 440 ++++++++++++++++++++++++++ src/test/CMakeLists.txt | 1 + 11 files changed, 1124 insertions(+), 14 deletions(-) create mode 100644 src/test/BehaviorSystemTest.cpp diff --git a/docs/plan.md b/docs/plan.md index aff80bb..a9526ed 100644 --- a/docs/plan.md +++ b/docs/plan.md @@ -11,7 +11,7 @@ Cross-references: `architecture.md` (design), `requirements.md` (REQ-* citations | 3 | Belt subsystem (placement, port interface, per-tile v1, splitter routing, clearTiles, visual iteration) | ✅ done | | 4 | Buildings + placement + belt↔building transport | ✅ done | | 5 | Scrap + ships skeleton (data + spawning, no AI) | ✅ done | -| 6 | Ship behavior systems + movement arbitration | ⬜ next | +| 6 | Ship behavior systems + movement arbitration | ✅ done | | 7 | Waves, threat accumulation, combat resolution, deaths & loot | ⬜ | | 8 | UI layer (GameWorldView, visuals.toml, panels, build/demolish, speed controls) | ⬜ | diff --git a/src/lib/sim/BuildingSystem.cpp b/src/lib/sim/BuildingSystem.cpp index afd595c..c0209ef 100644 --- a/src/lib/sim/BuildingSystem.cpp +++ b/src/lib/sim/BuildingSystem.cpp @@ -1,6 +1,7 @@ #include "BuildingSystem.h" #include +#include #include #include @@ -659,3 +660,61 @@ bool BuildingSystem::isTileOccupied(QPoint tile) const { return m_tileOccupancy.count({tile.x(), tile.y()}) > 0; } + +const Building* BuildingSystem::findNearestBuilding(QVector2D worldPos, + BuildingType type) const +{ + const Building* best = nullptr; + float bestDist = std::numeric_limits::max(); + for (const Building& b : m_buildings) + { + if (b.type != type) + { + continue; + } + QVector2D center(b.anchor.x() + b.footprint.width() / 2.0f, + b.anchor.y() + b.footprint.height() / 2.0f); + float dist = (center - worldPos).length(); + if (dist < bestDist) + { + bestDist = dist; + best = &b; + } + } + return best; +} + +bool BuildingSystem::deliverScrapToSalvageBay(EntityId bayId) +{ + Building* bay = nullptr; + for (Building& b : m_buildings) + { + if (b.id == bayId) + { + bay = &b; + break; + } + } + if (!bay || bay->type != BuildingType::SalvageBay) + { + return false; + } + if (static_cast(bay->outputBuffer.items.size()) >= bay->outputBuffer.capacity) + { + return false; + } + bay->outputBuffer.items.push_back(Item{ItemType{"scrap"}}); + return true; +} + +void BuildingSystem::healBuilding(EntityId id, float amount) +{ + for (Building& b : m_buildings) + { + if (b.id == id) + { + b.hp = std::min(b.hp + amount, b.maxHp); + return; + } + } +} diff --git a/src/lib/sim/BuildingSystem.h b/src/lib/sim/BuildingSystem.h index 582ffe1..7e08c05 100644 --- a/src/lib/sim/BuildingSystem.h +++ b/src/lib/sim/BuildingSystem.h @@ -10,6 +10,7 @@ #include #include +#include #include "BeltSystem.h" #include "Building.h" @@ -60,6 +61,16 @@ public: std::vector allSites() const; bool isTileOccupied(QPoint tile) const; + // Find nearest operational building of the given type; nullptr if none. + const Building* findNearestBuilding(QVector2D worldPos, BuildingType type) const; + + // Place one "scrap" item into a SalvageBay's output buffer. + // Returns false if bay not found, wrong type, or output buffer is full. + bool deliverScrapToSalvageBay(EntityId bayId); + + // Increase a building's HP by amount, clamped to maxHp. + void healBuilding(EntityId id, float amount); + private: struct BeltEntry { diff --git a/src/lib/sim/ScrapSystem.cpp b/src/lib/sim/ScrapSystem.cpp index 087b567..93ae90d 100644 --- a/src/lib/sim/ScrapSystem.cpp +++ b/src/lib/sim/ScrapSystem.cpp @@ -1,6 +1,7 @@ #include "ScrapSystem.h" #include +#include ScrapSystem::ScrapSystem(std::function allocateId) : m_allocateId(std::move(allocateId)) @@ -38,6 +39,20 @@ const Scrap* ScrapSystem::findScrap(EntityId id) const return nullptr; } +std::optional ScrapSystem::consume(EntityId id) +{ + for (auto it = m_scraps.begin(); it != m_scraps.end(); ++it) + { + if (it->id == id) + { + Scrap result = *it; + m_scraps.erase(it); + return result; + } + } + return std::nullopt; +} + std::vector ScrapSystem::allScraps() const { return m_scraps; diff --git a/src/lib/sim/ScrapSystem.h b/src/lib/sim/ScrapSystem.h index c7d46be..7c937bd 100644 --- a/src/lib/sim/ScrapSystem.h +++ b/src/lib/sim/ScrapSystem.h @@ -1,6 +1,7 @@ #pragma once #include +#include #include #include @@ -14,8 +15,9 @@ class ScrapSystem public: explicit ScrapSystem(std::function allocateId); - EntityId spawn(QVector2D position, int amount, Tick despawnAt); - void tickDespawn(Tick currentTick); + EntityId spawn(QVector2D position, int amount, Tick despawnAt); + void tickDespawn(Tick currentTick); + std::optional consume(EntityId id); // removes and returns scrap, or nullopt const Scrap* findScrap(EntityId id) const; std::vector allScraps() const; diff --git a/src/lib/sim/Ship.h b/src/lib/sim/Ship.h index 9b194f6..a541f97 100644 --- a/src/lib/sim/Ship.h +++ b/src/lib/sim/Ship.h @@ -23,8 +23,9 @@ struct Weapon struct SalvageCargo { - int capacity; - int current; + int capacity; + int current; + float collectionRange; // copy of ShipDef.salvage.collectionRange (tile units) }; struct RepairTool @@ -76,6 +77,8 @@ struct Ship int level; std::string blueprintId; + bool isEnemy = false; // true for enemy-faction ships (used by behavior systems) + std::optional weapon; std::optional cargo; std::optional repairTool; diff --git a/src/lib/sim/ShipSystem.cpp b/src/lib/sim/ShipSystem.cpp index 7682304..fd11cab 100644 --- a/src/lib/sim/ShipSystem.cpp +++ b/src/lib/sim/ShipSystem.cpp @@ -2,7 +2,13 @@ #include #include +#include +#include "Building.h" +#include "BuildingSystem.h" +#include "BuildingType.h" +#include "Scrap.h" +#include "ScrapSystem.h" #include "Tick.h" ShipSystem::ShipSystem(const GameConfig& config, @@ -24,7 +30,8 @@ const ShipDef* ShipSystem::findShipDef(const std::string& blueprintId) const return nullptr; } -EntityId ShipSystem::spawn(const std::string& blueprintId, int level, QVector2D position) +EntityId ShipSystem::spawn(const std::string& blueprintId, int level, QVector2D position, + bool isEnemy) { const ShipDef* def = findShipDef(blueprintId); assert(def != nullptr); @@ -42,6 +49,7 @@ EntityId ShipSystem::spawn(const std::string& blueprintId, int level, QVector2D / static_cast(kTickRateHz); ship.level = level; ship.blueprintId = blueprintId; + ship.isEnemy = isEnemy; ship.intent = MovementIntent{0, QVector2D(0.0f, 0.0f)}; if (def->combat) @@ -61,8 +69,9 @@ EntityId ShipSystem::spawn(const std::string& blueprintId, int level, QVector2D if (def->salvage) { SalvageCargo cargo; - cargo.capacity = def->salvage->cargoCapacity; - cargo.current = 0; + cargo.capacity = def->salvage->cargoCapacity; + cargo.current = 0; + cargo.collectionRange = static_cast(def->salvage->collectionRange); ship.cargo = cargo; ScrapCollector sc; @@ -118,3 +127,526 @@ void ShipSystem::forEach(std::function fn) fn(s); } } + +// --------------------------------------------------------------------------- +// 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) + { + if (s.id == id) + { + s.hp = std::min(s.hp + amount, s.maxHp); + return true; + } + } + return false; +} + +// --------------------------------------------------------------------------- +// clearMovementIntents +// --------------------------------------------------------------------------- + +void ShipSystem::clearMovementIntents() +{ + for (Ship& s : m_ships) + { + s.intent = MovementIntent{0, QVector2D(0.0f, 0.0f)}; + } +} + +// --------------------------------------------------------------------------- +// 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.threatResponse->engagementRange; + + if (!s.isEnemy) + { + // Player combat ship: target nearest enemy ship. + 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; + } + } + } + + if (s.threatResponse->currentTarget) + { + const Ship* target = findShip(*s.threatResponse->currentTarget); + if (target && 3 > s.intent.priority) + { + s.intent = MovementIntent{3, target->position}; + } + } + else + { + // No target: patrol rightward (aggressive). + if (3 > s.intent.priority) + { + 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(); + const float kPatrolRange = 3.0f; // scan radius relative to repair range + + 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() <= repairRange) + { + 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 = repairRange * kPatrolRange; + + 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. + const float sensorRange = collectRange * 5.0f; + float bestDist = 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())}; + } + } + } + } +} + +// --------------------------------------------------------------------------- +// tickMovement (tick-order step 10) +// --------------------------------------------------------------------------- + +void ShipSystem::tickMovement() +{ + for (Ship& s : m_ships) + { + if (s.intent.priority == 0) + { + s.velocity = QVector2D(0.0f, 0.0f); + continue; + } + QVector2D delta = s.intent.target - s.position; + float dist = delta.length(); + if (dist <= s.speedPerTick) + { + s.position = s.intent.target; + s.velocity = QVector2D(0.0f, 0.0f); + } + else + { + s.velocity = delta.normalized() * s.speedPerTick; + s.position += s.velocity; + } + } +} diff --git a/src/lib/sim/ShipSystem.h b/src/lib/sim/ShipSystem.h index 3f2f373..fb56cb9 100644 --- a/src/lib/sim/ShipSystem.h +++ b/src/lib/sim/ShipSystem.h @@ -9,22 +9,56 @@ #include "GameConfig.h" #include "Ship.h" +class BuildingSystem; +class ScrapSystem; + class ShipSystem { public: ShipSystem(const GameConfig& config, std::function allocateId); - EntityId spawn(const std::string& blueprintId, int level, QVector2D position); + // isEnemy defaults to false; set true for enemy-faction ships (step 7 wave spawning). + EntityId spawn(const std::string& blueprintId, int level, QVector2D position, + bool isEnemy = false); void despawn(EntityId id); const Ship* findShip(EntityId id) const; 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(); + private: const ShipDef* findShipDef(const std::string& blueprintId) 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); + 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 d494f21..53c8f62 100644 --- a/src/lib/sim/Simulation.cpp +++ b/src/lib/sim/Simulation.cpp @@ -27,11 +27,24 @@ Simulation::~Simulation() = default; void Simulation::tick() { m_buildingSystem->tickConstruction(m_currentTick); - m_buildingSystem->tickBeltPull(); // tick order step 3 - m_buildingSystem->tickProduction(m_currentTick); // step 4 - m_buildingSystem->tickBeltPush(); // step 5 - m_beltSystem.tick(); // step 6 - m_scrapSystem->tickDespawn(m_currentTick); // step 11 + m_buildingSystem->tickBeltPull(); // tick order step 3 + m_buildingSystem->tickProduction(m_currentTick); // step 4 + m_buildingSystem->tickBeltPush(); // step 5 + m_beltSystem.tick(); // step 6 + + // Step 7: ship behavior systems (movement arbitration via intent priority). + 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 + + // Steps 8 & 9: combat resolution and deaths — added in implementation step 7. + + // Step 10: advance ship positions from winning intents. + m_shipSystem->tickMovement(); + + m_scrapSystem->tickDespawn(m_currentTick); // step 11 ++m_currentTick; } diff --git a/src/test/BehaviorSystemTest.cpp b/src/test/BehaviorSystemTest.cpp new file mode 100644 index 0000000..8bb7135 --- /dev/null +++ b/src/test/BehaviorSystemTest.cpp @@ -0,0 +1,440 @@ +#include "catch.hpp" + +#include + +#include + +#include "BeltSystem.h" +#include "Building.h" +#include "BuildingSystem.h" +#include "BuildingType.h" +#include "ConfigLoader.h" +#include "Rotation.h" +#include "Scrap.h" +#include "ScrapSystem.h" +#include "Ship.h" +#include "ShipSystem.h" +#include "Tick.h" + +// --------------------------------------------------------------------------- +// Fixture +// --------------------------------------------------------------------------- + +static GameConfig loadConfig() +{ + return ConfigLoader::loadFromDirectory(DOTA_FACTORY_CONFIG_DIR); +} + +struct Fixture +{ + GameConfig cfg; + BeltSystem belts; + EntityId nextId; + int stock; + std::mt19937 rng; + BuildingSystem buildings; + ShipSystem ships; + ScrapSystem scraps; + Tick tick; + + explicit Fixture() + : cfg(loadConfig()) + , belts(cfg.world.beltSpeedTilesPerSecond) + , nextId(1) + , stock(0) + , rng(42) + , buildings(cfg, belts, + [this]() { return nextId++; }, + [this](int n) { stock += n; }, + rng) + , ships(cfg, [this]() { return nextId++; }) + , scraps([this]() { return nextId++; }) + , tick(0) + { + } + + // Run one full behavior+movement tick (steps 7 and 10). + void runBehaviorTick() + { + ships.clearMovementIntents(); + ships.tickHomeReturn(); + ships.tickThreatResponse(buildings); + ships.tickRepairBehavior(buildings); + ships.tickScrapCollector(scraps, buildings); + ships.tickMovement(); + ++tick; + } +}; + +// --------------------------------------------------------------------------- +// clearMovementIntents +// --------------------------------------------------------------------------- + +TEST_CASE("BehaviorSystem: clearMovementIntents resets all ships to priority 0", + "[behavior]") +{ + Fixture f; + const EntityId id = f.ships.spawn("interceptor", 1, QVector2D(0.0f, 0.0f)); + + // Manually write a non-zero intent. + f.ships.forEach([](Ship& s) { + s.intent = MovementIntent{3, QVector2D(10.0f, 0.0f)}; + }); + + f.ships.clearMovementIntents(); + + const Ship* s = f.ships.findShip(id); + REQUIRE(s != nullptr); + REQUIRE(s->intent.priority == 0); +} + +// --------------------------------------------------------------------------- +// tickMovement +// --------------------------------------------------------------------------- + +TEST_CASE("BehaviorSystem: tickMovement advances ship by speedPerTick toward target", + "[behavior]") +{ + Fixture f; + const EntityId id = f.ships.spawn("interceptor", 1, QVector2D(0.0f, 0.0f)); + + const float speed = f.ships.findShip(id)->speedPerTick; + const QVector2D target(100.0f, 0.0f); + + f.ships.forEach([&target](Ship& s) { + s.intent = MovementIntent{1, target}; + }); + f.ships.tickMovement(); + + const Ship* s = f.ships.findShip(id); + REQUIRE(s->position.x() == Approx(speed)); + REQUIRE(s->position.y() == Approx(0.0f)); +} + +TEST_CASE("BehaviorSystem: tickMovement stops exactly at target without overshoot", + "[behavior]") +{ + Fixture f; + const EntityId id = f.ships.spawn("interceptor", 1, QVector2D(0.0f, 0.0f)); + + // Place target closer than one tick's travel. + const float speed = f.ships.findShip(id)->speedPerTick; + const QVector2D target(speed * 0.5f, 0.0f); + + f.ships.forEach([&target](Ship& s) { + s.intent = MovementIntent{1, target}; + }); + f.ships.tickMovement(); + + const Ship* s = f.ships.findShip(id); + REQUIRE(s->position.x() == Approx(target.x())); + REQUIRE(s->position.y() == Approx(target.y())); +} + +// --------------------------------------------------------------------------- +// tickHomeReturn +// --------------------------------------------------------------------------- + +TEST_CASE("BehaviorSystem: tickHomeReturn does nothing when HP is above threshold", + "[behavior]") +{ + Fixture f; + const EntityId id = f.ships.spawn("interceptor", 1, QVector2D(0.0f, 0.0f)); + + f.ships.forEach([](Ship& s) { + s.homeReturn = HomeReturn{0.3f, QVector2D(-10.0f, 0.0f)}; + s.hp = s.maxHp; // full HP — above threshold + }); + + f.ships.clearMovementIntents(); + f.ships.tickHomeReturn(); + + REQUIRE(f.ships.findShip(id)->intent.priority == 0); +} + +TEST_CASE("BehaviorSystem: tickHomeReturn writes priority-4 intent toward homePos when HP is low", + "[behavior]") +{ + Fixture f; + const EntityId id = f.ships.spawn("interceptor", 1, QVector2D(0.0f, 0.0f)); + const QVector2D homePos(-10.0f, 0.0f); + + f.ships.forEach([&homePos](Ship& s) { + s.homeReturn = HomeReturn{0.5f, homePos}; + s.hp = s.maxHp * 0.2f; // below 50% threshold + }); + + f.ships.clearMovementIntents(); + f.ships.tickHomeReturn(); + + const Ship* s = f.ships.findShip(id); + REQUIRE(s->intent.priority == 4); + REQUIRE(s->intent.target.x() == Approx(homePos.x())); +} + +TEST_CASE("BehaviorSystem: tickHomeReturn priority-4 beats tickThreatResponse priority-3", + "[behavior]") +{ + Fixture f; + // Player ship with both homeReturn (low HP) and an enemy in range. + const EntityId playerId = f.ships.spawn("interceptor", 1, QVector2D(0.0f, 0.0f)); + f.ships.spawn("interceptor", 1, QVector2D(5.0f, 0.0f), /*isEnemy=*/true); + + const QVector2D homePos(-50.0f, 0.0f); + f.ships.forEach([&homePos, playerId](Ship& s) { + if (s.id == playerId) + { + s.homeReturn = HomeReturn{0.5f, homePos}; + s.hp = s.maxHp * 0.1f; + } + }); + + f.ships.clearMovementIntents(); + f.ships.tickHomeReturn(); + f.ships.tickThreatResponse(f.buildings); + + const Ship* s = f.ships.findShip(playerId); + REQUIRE(s->intent.priority == 4); + REQUIRE(s->intent.target.x() == Approx(homePos.x())); +} + +// --------------------------------------------------------------------------- +// tickThreatResponse — player ships +// --------------------------------------------------------------------------- + +TEST_CASE("BehaviorSystem: player combat ship acquires nearest enemy ship in range", + "[behavior]") +{ + Fixture f; + const EntityId playerId = f.ships.spawn("interceptor", 1, QVector2D(0.0f, 0.0f)); + // Spawn enemy within attack range (150 tile units). + const EntityId enemyId = f.ships.spawn("interceptor", 1, QVector2D(10.0f, 0.0f), + /*isEnemy=*/true); + + f.ships.clearMovementIntents(); + f.ships.tickThreatResponse(f.buildings); + + const Ship* player = f.ships.findShip(playerId); + REQUIRE(player->threatResponse.has_value()); + REQUIRE(player->threatResponse->currentTarget.has_value()); + REQUIRE(*player->threatResponse->currentTarget == enemyId); +} + +TEST_CASE("BehaviorSystem: player combat ship does not target friendly ships", + "[behavior]") +{ + Fixture f; + const EntityId id1 = f.ships.spawn("interceptor", 1, QVector2D(0.0f, 0.0f)); + f.ships.spawn("interceptor", 1, QVector2D(5.0f, 0.0f)); // also player (isEnemy=false) + + f.ships.clearMovementIntents(); + f.ships.tickThreatResponse(f.buildings); + + const Ship* s = f.ships.findShip(id1); + REQUIRE(s->threatResponse.has_value()); + REQUIRE_FALSE(s->threatResponse->currentTarget.has_value()); +} + +TEST_CASE("BehaviorSystem: player combat ship ignores enemy beyond engagement range", + "[behavior]") +{ + Fixture f; + const EntityId playerId = f.ships.spawn("interceptor", 1, QVector2D(0.0f, 0.0f)); + // Place enemy far beyond engagement range (150 tile units). + f.ships.spawn("interceptor", 1, QVector2D(500.0f, 0.0f), /*isEnemy=*/true); + + f.ships.clearMovementIntents(); + f.ships.tickThreatResponse(f.buildings); + + const Ship* s = f.ships.findShip(playerId); + REQUIRE_FALSE(s->threatResponse->currentTarget.has_value()); +} + +// --------------------------------------------------------------------------- +// tickThreatResponse — enemy ships +// --------------------------------------------------------------------------- + +TEST_CASE("BehaviorSystem: enemy ship acquires nearest player ship in range", + "[behavior]") +{ + Fixture f; + const EntityId playerId = f.ships.spawn("interceptor", 1, QVector2D(0.0f, 0.0f)); + const EntityId enemyId = f.ships.spawn("interceptor", 1, QVector2D(10.0f, 0.0f), + /*isEnemy=*/true); + + f.ships.clearMovementIntents(); + f.ships.tickThreatResponse(f.buildings); + + const Ship* enemy = f.ships.findShip(enemyId); + REQUIRE(enemy->threatResponse.has_value()); + REQUIRE(enemy->threatResponse->currentTarget.has_value()); + REQUIRE(*enemy->threatResponse->currentTarget == playerId); +} + +TEST_CASE("BehaviorSystem: enemy ship with no target writes leftward movement intent", + "[behavior]") +{ + Fixture f; + const EntityId enemyId = f.ships.spawn("interceptor", 1, QVector2D(100.0f, 0.0f), + /*isEnemy=*/true); + + f.ships.clearMovementIntents(); + f.ships.tickThreatResponse(f.buildings); + + const Ship* enemy = f.ships.findShip(enemyId); + REQUIRE(enemy->intent.priority == 3); + REQUIRE(enemy->intent.target.x() < 0.0f); // moving leftward (toward asteroid) +} + +// --------------------------------------------------------------------------- +// tickRepairBehavior +// --------------------------------------------------------------------------- + +TEST_CASE("BehaviorSystem: repair ship writes intent toward damaged friendly ship", + "[behavior]") +{ + Fixture f; + const EntityId repairId = f.ships.spawn("repair_ship", 1, QVector2D(0.0f, 0.0f)); + const EntityId friendlyId = f.ships.spawn("interceptor", 1, QVector2D(5.0f, 0.0f)); + + // Damage the friendly ship. + f.ships.forEach([friendlyId](Ship& s) { + if (s.id == friendlyId) + { + s.hp = s.maxHp * 0.5f; + } + }); + + f.ships.clearMovementIntents(); + f.ships.tickRepairBehavior(f.buildings); + + const Ship* repair = f.ships.findShip(repairId); + REQUIRE(repair->intent.priority == 2); + REQUIRE(repair->intent.target.x() == Approx(5.0f)); +} + +TEST_CASE("BehaviorSystem: repair ship heals damaged ally within repair range", + "[behavior]") +{ + Fixture f; + // Repair range = 80 tile units; place ships close together. + const EntityId repairId = f.ships.spawn("repair_ship", 1, QVector2D(0.0f, 0.0f)); + const EntityId friendlyId = f.ships.spawn("interceptor", 1, QVector2D(1.0f, 0.0f)); + + const float initialHp = f.ships.findShip(friendlyId)->maxHp * 0.5f; + f.ships.forEach([friendlyId, initialHp](Ship& s) { + if (s.id == friendlyId) + { + s.hp = initialHp; + } + }); + + f.ships.clearMovementIntents(); + f.ships.tickRepairBehavior(f.buildings); + + // repair_rate_formula = "5 + x" at x=1 → 6; hp should have increased. + const Ship* friendly = f.ships.findShip(friendlyId); + REQUIRE(friendly->hp > initialHp); +} + +TEST_CASE("BehaviorSystem: repair ship does not heal above maxHp", "[behavior]") +{ + Fixture f; + const EntityId repairId = f.ships.spawn("repair_ship", 1, QVector2D(0.0f, 0.0f)); + const EntityId friendlyId = f.ships.spawn("interceptor", 1, QVector2D(1.0f, 0.0f)); + + // Nearly full HP — one repair tick must not exceed maxHp. + f.ships.forEach([friendlyId](Ship& s) { + if (s.id == friendlyId) + { + s.hp = s.maxHp - 0.001f; + } + }); + + for (int i = 0; i < 5; ++i) + { + f.ships.clearMovementIntents(); + f.ships.tickRepairBehavior(f.buildings); + } + + const Ship* friendly = f.ships.findShip(friendlyId); + REQUIRE(friendly->hp <= friendly->maxHp); + REQUIRE(friendly->hp == Approx(friendly->maxHp)); +} + +// --------------------------------------------------------------------------- +// tickScrapCollector +// --------------------------------------------------------------------------- + +TEST_CASE("BehaviorSystem: salvage ship writes intent toward nearest scrap", "[behavior]") +{ + Fixture f; + const EntityId shipId = f.ships.spawn("salvage_ship", 1, QVector2D(0.0f, 0.0f)); + + // Scrap beyond collectionRange (50) but within sensorRange (250). + const QVector2D scrapPos(100.0f, 0.0f); + const Tick farFuture = 100000; + f.scraps.spawn(scrapPos, 1, farFuture); + + f.ships.clearMovementIntents(); + f.ships.tickScrapCollector(f.scraps, f.buildings); + + const Ship* s = f.ships.findShip(shipId); + REQUIRE(s->intent.priority == 1); + REQUIRE(s->intent.target.x() == Approx(scrapPos.x())); +} + +TEST_CASE("BehaviorSystem: salvage ship collects scrap on arrival", "[behavior]") +{ + Fixture f; + // Place scrap exactly at ship position so it is within collectionRange immediately. + const EntityId shipId = f.ships.spawn("salvage_ship", 1, QVector2D(0.0f, 0.0f)); + const Tick farFuture = 100000; + const EntityId scrapId = f.scraps.spawn(QVector2D(0.0f, 0.0f), 1, farFuture); + + f.ships.clearMovementIntents(); + f.ships.tickScrapCollector(f.scraps, f.buildings); + + const Ship* s = f.ships.findShip(shipId); + REQUIRE(s->cargo->current == 1); + REQUIRE(f.scraps.findScrap(scrapId) == nullptr); // consumed +} + +TEST_CASE("BehaviorSystem: full-cargo salvage ship moves toward SalvageBay", "[behavior]") +{ + Fixture f; + + // Place a SalvageBay building so the ship has somewhere to deliver. + // The SalvageBay occupies asteroid tiles (x < 0 convention); use negative coords. + // We bypass construction time by ticking until it is operational. + const EntityId bayId = f.buildings.place(BuildingType::SalvageBay, + QPoint(-4, 0), Rotation::East, 0); + Tick tick = 0; + // SalvageBay construction_time_seconds = 15 → 450 ticks; run 500 to be safe. + for (int i = 0; i < 500; ++i) + { + f.buildings.tickConstruction(tick++); + if (f.buildings.findBuilding(bayId) != nullptr) + { + break; + } + } + REQUIRE(f.buildings.findBuilding(bayId) != nullptr); + + // Spawn salvage ship and fill its cargo. + const EntityId shipId = f.ships.spawn("salvage_ship", 1, QVector2D(5.0f, 0.0f)); + f.ships.forEach([](Ship& s) { + if (s.cargo) + { + s.cargo->current = s.cargo->capacity; // full cargo + } + }); + + f.ships.clearMovementIntents(); + f.ships.tickScrapCollector(f.scraps, f.buildings); + + // Intent should point toward the bay (x < 0 area), not rightward. + const Ship* s = f.ships.findShip(shipId); + REQUIRE(s->intent.priority == 1); + REQUIRE(s->intent.target.x() < s->position.x()); +} diff --git a/src/test/CMakeLists.txt b/src/test/CMakeLists.txt index a9fd4e7..2828916 100644 --- a/src/test/CMakeLists.txt +++ b/src/test/CMakeLists.txt @@ -11,4 +11,5 @@ add_files( BuildingTest.cpp ShipTest.cpp ScrapTest.cpp + BehaviorSystemTest.cpp )