From 411be72a5c7cae5799229eb66c72dd10ca72fd9c Mon Sep 17 00:00:00 2001 From: mlangkabel Date: Mon, 20 Apr 2026 07:32:18 +0200 Subject: [PATCH] implement scrap and ship skeleton --- docs/plan.md | 4 +- src/CMakeLists.txt | 2 +- src/lib/sim/CMakeLists.txt | 6 ++ src/lib/sim/Scrap.h | 14 +++ src/lib/sim/ScrapSystem.cpp | 44 ++++++++ src/lib/sim/ScrapSystem.h | 26 +++++ src/lib/sim/Ship.h | 90 +++++++++++++++++ src/lib/sim/ShipSystem.cpp | 120 ++++++++++++++++++++++ src/lib/sim/ShipSystem.h | 31 ++++++ src/lib/sim/Simulation.cpp | 25 +++++ src/lib/sim/Simulation.h | 8 ++ src/test/CMakeLists.txt | 2 + src/test/ScrapTest.cpp | 83 +++++++++++++++ src/test/ShipTest.cpp | 194 ++++++++++++++++++++++++++++++++++++ 14 files changed, 646 insertions(+), 3 deletions(-) create mode 100644 src/lib/sim/Scrap.h create mode 100644 src/lib/sim/ScrapSystem.cpp create mode 100644 src/lib/sim/ScrapSystem.h create mode 100644 src/lib/sim/Ship.h create mode 100644 src/lib/sim/ShipSystem.cpp create mode 100644 src/lib/sim/ShipSystem.h create mode 100644 src/test/ScrapTest.cpp create mode 100644 src/test/ShipTest.cpp diff --git a/docs/plan.md b/docs/plan.md index 8a1180c..aff80bb 100644 --- a/docs/plan.md +++ b/docs/plan.md @@ -10,8 +10,8 @@ Cross-references: `architecture.md` (design), `requirements.md` (REQ-* citations | 2 | Simulation shell + TickDriver + entity id allocator + event queues | ✅ done | | 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) | ⬜ next | -| 6 | Ship behavior systems + movement arbitration | ⬜ | +| 5 | Scrap + ships skeleton (data + spawning, no AI) | ✅ done | +| 6 | Ship behavior systems + movement arbitration | ⬜ next | | 7 | Waves, threat accumulation, combat resolution, deaths & loot | ⬜ | | 8 | UI layer (GameWorldView, visuals.toml, panels, build/demolish, speed controls) | ⬜ | diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 4cab909..372421a 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -55,7 +55,7 @@ set_property(TARGET ${TARGET_LIB_NAME} PROPERTY INCLUDE_DIRECTORIES "${TARGET_LIB_INCLUDE_DIRS}" "${LIB_INCLUDE_PATH}" ) -target_link_libraries(${TARGET_LIB_NAME} Qt5::Core) +target_link_libraries(${TARGET_LIB_NAME} Qt5::Core Qt5::Gui) target_compile_definitions(${TARGET_LIB_NAME} PRIVATE TOML_FLOAT_CHARCONV=0) set(CMAKE_AUTOMOC OFF) diff --git a/src/lib/sim/CMakeLists.txt b/src/lib/sim/CMakeLists.txt index c4cf086..339ee67 100644 --- a/src/lib/sim/CMakeLists.txt +++ b/src/lib/sim/CMakeLists.txt @@ -5,6 +5,10 @@ SET(HDRS ${CMAKE_CURRENT_SOURCE_DIR}/BeltSystem.h ${CMAKE_CURRENT_SOURCE_DIR}/Building.h ${CMAKE_CURRENT_SOURCE_DIR}/BuildingSystem.h + ${CMAKE_CURRENT_SOURCE_DIR}/Scrap.h + ${CMAKE_CURRENT_SOURCE_DIR}/Ship.h + ${CMAKE_CURRENT_SOURCE_DIR}/ShipSystem.h + ${CMAKE_CURRENT_SOURCE_DIR}/ScrapSystem.h PARENT_SCOPE ) @@ -14,6 +18,8 @@ SET(SRCS ${CMAKE_CURRENT_SOURCE_DIR}/TickDriver.cpp ${CMAKE_CURRENT_SOURCE_DIR}/BeltSystem.cpp ${CMAKE_CURRENT_SOURCE_DIR}/BuildingSystem.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/ShipSystem.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/ScrapSystem.cpp PARENT_SCOPE ) diff --git a/src/lib/sim/Scrap.h b/src/lib/sim/Scrap.h new file mode 100644 index 0000000..c9809d2 --- /dev/null +++ b/src/lib/sim/Scrap.h @@ -0,0 +1,14 @@ +#pragma once + +#include + +#include "EntityId.h" +#include "Tick.h" + +struct Scrap +{ + EntityId id; + QVector2D position; + int amount; + Tick despawnAt; +}; diff --git a/src/lib/sim/ScrapSystem.cpp b/src/lib/sim/ScrapSystem.cpp new file mode 100644 index 0000000..087b567 --- /dev/null +++ b/src/lib/sim/ScrapSystem.cpp @@ -0,0 +1,44 @@ +#include "ScrapSystem.h" + +#include + +ScrapSystem::ScrapSystem(std::function allocateId) + : m_allocateId(std::move(allocateId)) +{ +} + +EntityId ScrapSystem::spawn(QVector2D position, int amount, Tick despawnAt) +{ + Scrap s; + s.id = m_allocateId(); + s.position = position; + s.amount = amount; + s.despawnAt = despawnAt; + m_scraps.push_back(s); + return s.id; +} + +void ScrapSystem::tickDespawn(Tick currentTick) +{ + m_scraps.erase( + std::remove_if(m_scraps.begin(), m_scraps.end(), + [currentTick](const Scrap& s) { return s.despawnAt <= currentTick; }), + m_scraps.end()); +} + +const Scrap* ScrapSystem::findScrap(EntityId id) const +{ + for (const Scrap& s : m_scraps) + { + if (s.id == id) + { + return &s; + } + } + return nullptr; +} + +std::vector ScrapSystem::allScraps() const +{ + return m_scraps; +} diff --git a/src/lib/sim/ScrapSystem.h b/src/lib/sim/ScrapSystem.h new file mode 100644 index 0000000..c7d46be --- /dev/null +++ b/src/lib/sim/ScrapSystem.h @@ -0,0 +1,26 @@ +#pragma once + +#include +#include + +#include + +#include "EntityId.h" +#include "Scrap.h" +#include "Tick.h" + +class ScrapSystem +{ +public: + explicit ScrapSystem(std::function allocateId); + + EntityId spawn(QVector2D position, int amount, Tick despawnAt); + void tickDespawn(Tick currentTick); + + const Scrap* findScrap(EntityId id) const; + std::vector allScraps() const; + +private: + std::function m_allocateId; + std::vector m_scraps; +}; diff --git a/src/lib/sim/Ship.h b/src/lib/sim/Ship.h new file mode 100644 index 0000000..9b194f6 --- /dev/null +++ b/src/lib/sim/Ship.h @@ -0,0 +1,90 @@ +#pragma once + +#include +#include + +#include + +#include "EntityId.h" +#include "MovementIntent.h" + +// --------------------------------------------------------------------------- +// Hardware components — derived from config at spawn, stored on ship +// --------------------------------------------------------------------------- + +struct Weapon +{ + float damage; + float range; + float fireRateHz; + float cooldownTicks; + std::optional currentTarget; +}; + +struct SalvageCargo +{ + int capacity; + int current; +}; + +struct RepairTool +{ + float ratePerTick; + float range; + std::optional currentTarget; +}; + +// --------------------------------------------------------------------------- +// Behavior components — AI state consumed by step-6 behavior systems +// --------------------------------------------------------------------------- + +struct ThreatResponse +{ + float engagementRange; + std::optional currentTarget; +}; + +struct ScrapCollector +{ + std::optional scrapTarget; + EntityId deliveryBay; // kInvalidEntityId until assigned at a salvage bay +}; + +struct RepairBehavior +{ + std::optional currentTarget; +}; + +struct HomeReturn +{ + float retreatHpFraction; + QVector2D homePos; +}; + +// --------------------------------------------------------------------------- +// Ship +// --------------------------------------------------------------------------- + +struct Ship +{ + EntityId id; + QVector2D position; + QVector2D velocity; + float hp; + float maxHp; + float speedPerTick; // pre-evaluated from speedFormula / kTickRateHz + int level; + std::string blueprintId; + + std::optional weapon; + std::optional cargo; + std::optional repairTool; + std::optional threatResponse; + std::optional scrapCollector; + std::optional repairBehavior; + std::optional homeReturn; + + // Cleared at the start of the behavior step each tick; the highest-priority + // write from behavior systems wins (architecture.md §Movement Arbitration). + MovementIntent intent; +}; diff --git a/src/lib/sim/ShipSystem.cpp b/src/lib/sim/ShipSystem.cpp new file mode 100644 index 0000000..7682304 --- /dev/null +++ b/src/lib/sim/ShipSystem.cpp @@ -0,0 +1,120 @@ +#include "ShipSystem.h" + +#include +#include + +#include "Tick.h" + +ShipSystem::ShipSystem(const GameConfig& config, + std::function allocateId) + : m_config(config) + , m_allocateId(std::move(allocateId)) +{ +} + +const ShipDef* ShipSystem::findShipDef(const std::string& blueprintId) const +{ + for (const ShipDef& def : m_config.ships.ships) + { + if (def.id == blueprintId) + { + return &def; + } + } + return nullptr; +} + +EntityId ShipSystem::spawn(const std::string& blueprintId, int level, QVector2D position) +{ + const ShipDef* def = findShipDef(blueprintId); + assert(def != nullptr); + + const double x = static_cast(level); + + Ship ship; + ship.id = m_allocateId(); + ship.position = position; + ship.velocity = QVector2D(0.0f, 0.0f); + ship.maxHp = static_cast(def->health.hpFormula.evaluate(x)); + ship.hp = ship.maxHp; + ship.speedPerTick = static_cast( + def->movement.speedFormula.evaluate(x)) + / static_cast(kTickRateHz); + ship.level = level; + ship.blueprintId = blueprintId; + ship.intent = MovementIntent{0, QVector2D(0.0f, 0.0f)}; + + if (def->combat) + { + Weapon w; + w.damage = static_cast(def->combat->damageFormula.evaluate(x)); + w.range = static_cast(def->combat->attackRangeFormula.evaluate(x)); + w.fireRateHz = static_cast(def->combat->attackRateFormula.evaluate(x)); + w.cooldownTicks = 0.0f; + ship.weapon = w; + + ThreatResponse tr; + tr.engagementRange = w.range; + ship.threatResponse = tr; + } + + if (def->salvage) + { + SalvageCargo cargo; + cargo.capacity = def->salvage->cargoCapacity; + cargo.current = 0; + ship.cargo = cargo; + + ScrapCollector sc; + sc.scrapTarget = std::nullopt; + sc.deliveryBay = kInvalidEntityId; + ship.scrapCollector = sc; + } + + if (def->repair) + { + RepairTool rt; + rt.ratePerTick = static_cast(def->repair->repairRateFormula.evaluate(x)); + rt.range = static_cast(def->repair->repairRangeFormula.evaluate(x)); + ship.repairTool = rt; + + RepairBehavior rb; + ship.repairBehavior = rb; + } + + m_ships.push_back(ship); + return ship.id; +} + +void ShipSystem::despawn(EntityId id) +{ + m_ships.erase( + std::remove_if(m_ships.begin(), m_ships.end(), + [id](const Ship& s) { return s.id == id; }), + m_ships.end()); +} + +const Ship* ShipSystem::findShip(EntityId id) const +{ + for (const Ship& s : m_ships) + { + if (s.id == id) + { + return &s; + } + } + return nullptr; +} + +std::vector ShipSystem::allShips() const +{ + return m_ships; +} + +void ShipSystem::forEach(std::function fn) +{ + for (Ship& s : m_ships) + { + fn(s); + } +} diff --git a/src/lib/sim/ShipSystem.h b/src/lib/sim/ShipSystem.h new file mode 100644 index 0000000..3f2f373 --- /dev/null +++ b/src/lib/sim/ShipSystem.h @@ -0,0 +1,31 @@ +#pragma once + +#include +#include + +#include + +#include "EntityId.h" +#include "GameConfig.h" +#include "Ship.h" + +class ShipSystem +{ +public: + ShipSystem(const GameConfig& config, + std::function allocateId); + + EntityId spawn(const std::string& blueprintId, int level, QVector2D position); + void despawn(EntityId id); + + const Ship* findShip(EntityId id) const; + std::vector allShips() const; + void forEach(std::function fn); + +private: + const ShipDef* findShipDef(const std::string& blueprintId) 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 9a81d79..d494f21 100644 --- a/src/lib/sim/Simulation.cpp +++ b/src/lib/sim/Simulation.cpp @@ -1,6 +1,8 @@ #include "Simulation.h" #include "BuildingSystem.h" +#include "ScrapSystem.h" +#include "ShipSystem.h" Simulation::Simulation(const GameConfig& config, unsigned int seed) : m_config(config) @@ -16,6 +18,8 @@ Simulation::Simulation(const GameConfig& config, unsigned int seed) [this]() { return allocateId(); }, [this](int amount) { m_buildingBlocksStock += amount; }, m_rng); + m_shipSystem = std::make_unique(config, [this]() { return allocateId(); }); + m_scrapSystem = std::make_unique([this]() { return allocateId(); }); } Simulation::~Simulation() = default; @@ -27,6 +31,7 @@ void Simulation::tick() 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_currentTick; } @@ -75,6 +80,26 @@ const BeltSystem& Simulation::belts() const return m_beltSystem; } +ShipSystem& Simulation::ships() +{ + return *m_shipSystem; +} + +const ShipSystem& Simulation::ships() const +{ + return *m_shipSystem; +} + +ScrapSystem& Simulation::scraps() +{ + return *m_scrapSystem; +} + +const ScrapSystem& Simulation::scraps() const +{ + return *m_scrapSystem; +} + EntityId Simulation::allocateId() { return m_nextId++; diff --git a/src/lib/sim/Simulation.h b/src/lib/sim/Simulation.h index 49de54b..f3e135d 100644 --- a/src/lib/sim/Simulation.h +++ b/src/lib/sim/Simulation.h @@ -12,6 +12,8 @@ #include "Tick.h" class BuildingSystem; +class ShipSystem; +class ScrapSystem; class Simulation { @@ -36,6 +38,10 @@ public: const BuildingSystem& buildings() const; BeltSystem& belts(); const BeltSystem& belts() const; + ShipSystem& ships(); + const ShipSystem& ships() const; + ScrapSystem& scraps(); + const ScrapSystem& scraps() const; private: EntityId allocateId(); // Strictly increasing; never returns kInvalidEntityId. @@ -49,6 +55,8 @@ private: BeltSystem m_beltSystem; std::unique_ptr m_buildingSystem; + std::unique_ptr m_shipSystem; + std::unique_ptr m_scrapSystem; std::vector m_fireEvents; std::vector m_blueprintDropEvents; diff --git a/src/test/CMakeLists.txt b/src/test/CMakeLists.txt index 78aa677..a9fd4e7 100644 --- a/src/test/CMakeLists.txt +++ b/src/test/CMakeLists.txt @@ -9,4 +9,6 @@ add_files( BeltSystemTest.cpp SurfaceMaskTest.cpp BuildingTest.cpp + ShipTest.cpp + ScrapTest.cpp ) diff --git a/src/test/ScrapTest.cpp b/src/test/ScrapTest.cpp new file mode 100644 index 0000000..d7c5a3f --- /dev/null +++ b/src/test/ScrapTest.cpp @@ -0,0 +1,83 @@ +#include "catch.hpp" + +#include + +#include "EntityId.h" +#include "Scrap.h" +#include "ScrapSystem.h" + +// --------------------------------------------------------------------------- +// Spawn +// --------------------------------------------------------------------------- + +TEST_CASE("ScrapSystem: spawn returns a findable scrap with correct fields", "[scrap]") +{ + EntityId nextId = 1; + ScrapSystem ss([&nextId]() { return nextId++; }); + + const EntityId id = ss.spawn(QVector2D(3.0f, 4.0f), 5, 100); + const Scrap* s = ss.findScrap(id); + + REQUIRE(s != nullptr); + REQUIRE(s->amount == 5); + REQUIRE(s->despawnAt == 100); +} + +// --------------------------------------------------------------------------- +// Despawn timing +// --------------------------------------------------------------------------- + +TEST_CASE("ScrapSystem: scrap still present one tick before despawnAt", "[scrap]") +{ + EntityId nextId = 1; + ScrapSystem ss([&nextId]() { return nextId++; }); + + const EntityId id = ss.spawn(QVector2D(0.0f, 0.0f), 1, 50); + + ss.tickDespawn(49); + REQUIRE(ss.findScrap(id) != nullptr); +} + +TEST_CASE("ScrapSystem: scrap removed at despawnAt tick", "[scrap]") +{ + EntityId nextId = 1; + ScrapSystem ss([&nextId]() { return nextId++; }); + + const EntityId id = ss.spawn(QVector2D(0.0f, 0.0f), 1, 50); + + ss.tickDespawn(50); + REQUIRE(ss.findScrap(id) == nullptr); +} + +// --------------------------------------------------------------------------- +// Selective removal +// --------------------------------------------------------------------------- + +TEST_CASE("ScrapSystem: tickDespawn removes only expired scraps", "[scrap]") +{ + EntityId nextId = 1; + ScrapSystem ss([&nextId]() { return nextId++; }); + + const EntityId earlyId = ss.spawn(QVector2D(0.0f, 0.0f), 1, 30); + const EntityId lateId = ss.spawn(QVector2D(1.0f, 0.0f), 2, 60); + + ss.tickDespawn(30); + + REQUIRE(ss.findScrap(earlyId) == nullptr); + REQUIRE(ss.findScrap(lateId) != nullptr); +} + +// --------------------------------------------------------------------------- +// Entity ids +// --------------------------------------------------------------------------- + +TEST_CASE("ScrapSystem: spawned scraps receive strictly increasing entity ids", "[scrap]") +{ + EntityId nextId = 1; + ScrapSystem ss([&nextId]() { return nextId++; }); + + const EntityId id1 = ss.spawn(QVector2D(0.0f, 0.0f), 1, 100); + const EntityId id2 = ss.spawn(QVector2D(1.0f, 0.0f), 2, 200); + + REQUIRE(id2 > id1); +} diff --git a/src/test/ShipTest.cpp b/src/test/ShipTest.cpp new file mode 100644 index 0000000..88f6b65 --- /dev/null +++ b/src/test/ShipTest.cpp @@ -0,0 +1,194 @@ +#include "catch.hpp" + +#include +#include +#include +#include + +#include + +#include "ConfigLoader.h" +#include "EntityId.h" +#include "Ship.h" +#include "ShipSystem.h" +#include "Tick.h" + +static GameConfig loadConfig() +{ + return ConfigLoader::loadFromDirectory(DOTA_FACTORY_CONFIG_DIR); +} + +// --------------------------------------------------------------------------- +// Combat ship +// --------------------------------------------------------------------------- + +TEST_CASE("ShipSystem: interceptor spawn has weapon and threatResponse, no cargo or repair", + "[ship]") +{ + const GameConfig cfg = loadConfig(); + EntityId nextId = 1; + ShipSystem ss(cfg, [&nextId]() { return nextId++; }); + + const EntityId id = ss.spawn("interceptor", 1, QVector2D(0.0f, 0.0f)); + const Ship* ship = ss.findShip(id); + + REQUIRE(ship != nullptr); + REQUIRE(ship->weapon.has_value()); + REQUIRE(ship->threatResponse.has_value()); + REQUIRE_FALSE(ship->cargo.has_value()); + REQUIRE_FALSE(ship->repairTool.has_value()); + REQUIRE_FALSE(ship->repairBehavior.has_value()); + REQUIRE_FALSE(ship->scrapCollector.has_value()); +} + +TEST_CASE("ShipSystem: interceptor level 1 stats match config formulas", "[ship]") +{ + const GameConfig cfg = loadConfig(); + EntityId nextId = 1; + ShipSystem ss(cfg, [&nextId]() { return nextId++; }); + + const EntityId id = ss.spawn("interceptor", 1, QVector2D(0.0f, 0.0f)); + const Ship* ship = ss.findShip(id); + + REQUIRE(ship != nullptr); + // hp_formula = "40 + 5*x" at x=1 → 45 + REQUIRE(ship->maxHp == Approx(45.0f)); + REQUIRE(ship->hp == Approx(45.0f)); + // damage_formula = "10 + 2*x" at x=1 → 12 + REQUIRE(ship->weapon->damage == Approx(12.0f)); + // attack_range_formula = "150" + REQUIRE(ship->weapon->range == Approx(150.0f)); + // threatResponse.engagementRange mirrors weapon range + REQUIRE(ship->threatResponse->engagementRange == Approx(150.0f)); + // cooldownTicks starts at 0 + REQUIRE(ship->weapon->cooldownTicks == Approx(0.0f)); +} + +TEST_CASE("ShipSystem: interceptor level 5 hp matches formula", "[ship]") +{ + const GameConfig cfg = loadConfig(); + EntityId nextId = 1; + ShipSystem ss(cfg, [&nextId]() { return nextId++; }); + + const EntityId id = ss.spawn("interceptor", 5, QVector2D(0.0f, 0.0f)); + const Ship* ship = ss.findShip(id); + + // hp_formula = "40 + 5*x" at x=5 → 65 + REQUIRE(ship->maxHp == Approx(65.0f)); +} + +TEST_CASE("ShipSystem: interceptor level 0 speedPerTick matches formula / kTickRateHz", "[ship]") +{ + const GameConfig cfg = loadConfig(); + EntityId nextId = 1; + ShipSystem ss(cfg, [&nextId]() { return nextId++; }); + + const EntityId id = ss.spawn("interceptor", 0, QVector2D(0.0f, 0.0f)); + const Ship* ship = ss.findShip(id); + + // speed_formula = "200 + 5*x" at x=0 → 200; speedPerTick = 200/30 + const float expected = 200.0f / static_cast(kTickRateHz); + REQUIRE(ship->speedPerTick == Approx(expected)); +} + +// --------------------------------------------------------------------------- +// Salvage ship +// --------------------------------------------------------------------------- + +TEST_CASE("ShipSystem: salvage_ship spawn has cargo and scrapCollector, no weapon", + "[ship]") +{ + const GameConfig cfg = loadConfig(); + EntityId nextId = 1; + ShipSystem ss(cfg, [&nextId]() { return nextId++; }); + + const EntityId id = ss.spawn("salvage_ship", 1, QVector2D(0.0f, 0.0f)); + const Ship* ship = ss.findShip(id); + + REQUIRE(ship != nullptr); + REQUIRE(ship->cargo.has_value()); + REQUIRE(ship->scrapCollector.has_value()); + REQUIRE_FALSE(ship->weapon.has_value()); + REQUIRE_FALSE(ship->repairTool.has_value()); +} + +TEST_CASE("ShipSystem: salvage_ship cargo capacity matches config", "[ship]") +{ + const GameConfig cfg = loadConfig(); + EntityId nextId = 1; + ShipSystem ss(cfg, [&nextId]() { return nextId++; }); + + const EntityId id = ss.spawn("salvage_ship", 1, QVector2D(0.0f, 0.0f)); + const Ship* ship = ss.findShip(id); + + // cargo_capacity = 10 + REQUIRE(ship->cargo->capacity == 10); + REQUIRE(ship->cargo->current == 0); + REQUIRE(ship->scrapCollector->deliveryBay == kInvalidEntityId); + REQUIRE_FALSE(ship->scrapCollector->scrapTarget.has_value()); +} + +// --------------------------------------------------------------------------- +// Repair ship +// --------------------------------------------------------------------------- + +TEST_CASE("ShipSystem: repair_ship spawn has repairTool and repairBehavior, no weapon", + "[ship]") +{ + const GameConfig cfg = loadConfig(); + EntityId nextId = 1; + ShipSystem ss(cfg, [&nextId]() { return nextId++; }); + + const EntityId id = ss.spawn("repair_ship", 1, QVector2D(0.0f, 0.0f)); + const Ship* ship = ss.findShip(id); + + REQUIRE(ship != nullptr); + REQUIRE(ship->repairTool.has_value()); + REQUIRE(ship->repairBehavior.has_value()); + REQUIRE_FALSE(ship->weapon.has_value()); + REQUIRE_FALSE(ship->cargo.has_value()); +} + +TEST_CASE("ShipSystem: repair_ship level 1 repair stats match config formulas", "[ship]") +{ + const GameConfig cfg = loadConfig(); + EntityId nextId = 1; + ShipSystem ss(cfg, [&nextId]() { return nextId++; }); + + const EntityId id = ss.spawn("repair_ship", 1, QVector2D(0.0f, 0.0f)); + const Ship* ship = ss.findShip(id); + + // repair_rate_formula = "5 + x" at x=1 → 6 + REQUIRE(ship->repairTool->ratePerTick == Approx(6.0f)); + // repair_range_formula = "80" + REQUIRE(ship->repairTool->range == Approx(80.0f)); +} + +// --------------------------------------------------------------------------- +// Entity ids and removal +// --------------------------------------------------------------------------- + +TEST_CASE("ShipSystem: spawned ships receive strictly increasing entity ids", "[ship]") +{ + const GameConfig cfg = loadConfig(); + EntityId nextId = 1; + ShipSystem ss(cfg, [&nextId]() { return nextId++; }); + + const EntityId id1 = ss.spawn("interceptor", 1, QVector2D(0.0f, 0.0f)); + const EntityId id2 = ss.spawn("salvage_ship", 1, QVector2D(1.0f, 0.0f)); + + REQUIRE(id2 > id1); +} + +TEST_CASE("ShipSystem: despawn removes the ship", "[ship]") +{ + const GameConfig cfg = loadConfig(); + EntityId nextId = 1; + ShipSystem ss(cfg, [&nextId]() { return nextId++; }); + + const EntityId id = ss.spawn("interceptor", 1, QVector2D(0.0f, 0.0f)); + REQUIRE(ss.findShip(id) != nullptr); + + ss.despawn(id); + REQUIRE(ss.findShip(id) == nullptr); +}