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

@@ -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
)