implement ship behaviors

This commit is contained in:
2026-04-20 08:29:53 +02:00
parent 8b84297b41
commit 65de4ddc5c
11 changed files with 1124 additions and 14 deletions

View File

@@ -1,6 +1,7 @@
#include "BuildingSystem.h"
#include <cassert>
#include <limits>
#include <random>
#include <set>
@@ -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<float>::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<int>(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;
}
}
}

View File

@@ -10,6 +10,7 @@
#include <vector>
#include <QPoint>
#include <QVector2D>
#include "BeltSystem.h"
#include "Building.h"
@@ -60,6 +61,16 @@ public:
std::vector<ConstructionSite> 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
{

View File

@@ -1,6 +1,7 @@
#include "ScrapSystem.h"
#include <algorithm>
#include <optional>
ScrapSystem::ScrapSystem(std::function<EntityId()> allocateId)
: m_allocateId(std::move(allocateId))
@@ -38,6 +39,20 @@ const Scrap* ScrapSystem::findScrap(EntityId id) const
return nullptr;
}
std::optional<Scrap> 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<Scrap> ScrapSystem::allScraps() const
{
return m_scraps;

View File

@@ -1,6 +1,7 @@
#pragma once
#include <functional>
#include <optional>
#include <vector>
#include <QVector2D>
@@ -14,8 +15,9 @@ class ScrapSystem
public:
explicit ScrapSystem(std::function<EntityId()> 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<Scrap> consume(EntityId id); // removes and returns scrap, or nullopt
const Scrap* findScrap(EntityId id) const;
std::vector<Scrap> allScraps() const;

View File

@@ -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> weapon;
std::optional<SalvageCargo> cargo;
std::optional<RepairTool> repairTool;

View File

@@ -2,7 +2,13 @@
#include <algorithm>
#include <cassert>
#include <limits>
#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<float>(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<float>(def->salvage->collectionRange);
ship.cargo = cargo;
ScrapCollector sc;
@@ -118,3 +127,526 @@ void ShipSystem::forEach(std::function<void(Ship&)> 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<Building> 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<Building> 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<BuildingSystem&>(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<QVector2D> 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;
}
}
}

View File

@@ -9,22 +9,56 @@
#include "GameConfig.h"
#include "Ship.h"
class BuildingSystem;
class ScrapSystem;
class ShipSystem
{
public:
ShipSystem(const GameConfig& config,
std::function<EntityId()> 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<Ship> allShips() const;
void forEach(std::function<void(Ship&)> 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<EntityId()> m_allocateId;
std::vector<Ship> m_ships;

View File

@@ -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;
}

View File

@@ -0,0 +1,440 @@
#include "catch.hpp"
#include <random>
#include <QVector2D>
#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());
}

View File

@@ -11,4 +11,5 @@ add_files(
BuildingTest.cpp
ShipTest.cpp
ScrapTest.cpp
BehaviorSystemTest.cpp
)