diff --git a/bin/config/ships.toml b/bin/config/ships.toml index 3a3fc43..0c4aed7 100644 --- a/bin/config/ships.toml +++ b/bin/config/ships.toml @@ -16,6 +16,9 @@ hp_formula = "4" [ship.movement] speed_formula = "4" +[ship.sensor] +sensor_range_formula = "15" + [ship.combat] damage_formula = "1" attack_range_formula = "10" @@ -43,6 +46,9 @@ hp_formula = "120 + 15*x" [ship.movement] speed_formula = "120" +[ship.sensor] +sensor_range_formula = "300" + [ship.combat] damage_formula = "12 + 2*x" attack_range_formula = "250" @@ -70,6 +76,9 @@ hp_formula = "40 + 4*x" [ship.movement] speed_formula = "110" +[ship.sensor] +sensor_range_formula = "250" + [ship.salvage] collection_range = 50 cargo_capacity = 10 @@ -96,6 +105,9 @@ hp_formula = "60 + 5*x" [ship.movement] speed_formula = "130" +[ship.sensor] +sensor_range_formula = "250" + [ship.repair] repair_rate_formula = "5 + x" repair_range_formula = "80" diff --git a/src/lib/config/ConfigLoader.cpp b/src/lib/config/ConfigLoader.cpp index 0121119..4e4b1a7 100644 --- a/src/lib/config/ConfigLoader.cpp +++ b/src/lib/config/ConfigLoader.cpp @@ -397,6 +397,14 @@ ShipsConfig ConfigLoader::loadShips(const std::string& path) def.movement.speedFormula = requireFormula(mMt["speed_formula"], file, mPath + ".speed_formula"); } + // Sensor + { + const std::string snsPath = elemPath + ".sensor"; + const toml::table& snsTable = requireTable(mt["sensor"], file, snsPath); + toml::table& snsMt = const_cast(snsTable); + def.sensor.sensorRangeFormula = requireFormula(snsMt["sensor_range_formula"], file, snsPath + ".sensor_range_formula"); + } + // Loot { const std::string lPath = elemPath + ".loot"; diff --git a/src/lib/config/ShipsConfig.h b/src/lib/config/ShipsConfig.h index 2271d97..7859223 100644 --- a/src/lib/config/ShipsConfig.h +++ b/src/lib/config/ShipsConfig.h @@ -33,6 +33,11 @@ struct ShipMovement Formula speedFormula; // REQ-SHP-STATS, REQ-SHP-MOVEMENT }; +struct ShipSensor +{ + Formula sensorRangeFormula; // REQ-SHP-SENSOR, REQ-SHP-STATS +}; + struct ShipCombat { Formula damageFormula; @@ -69,6 +74,7 @@ struct ShipDef ShipThreat threat; ShipHealth health; ShipMovement movement; + ShipSensor sensor; ShipLoot loot; // Role-specific sections. A ship is a combat ship if combat is present, diff --git a/src/lib/sim/Ship.h b/src/lib/sim/Ship.h index 5daf804..f51f435 100644 --- a/src/lib/sim/Ship.h +++ b/src/lib/sim/Ship.h @@ -41,7 +41,6 @@ struct RepairTool struct ThreatResponse { - float engagementRange; std::optional currentTarget; }; @@ -78,7 +77,8 @@ struct Ship QVector2D velocity; float hp; float maxHp; - float speedPerTick; // pre-evaluated from speedFormula / kTickRateHz + float speedPerTick; // pre-evaluated from speedFormula / kTickRateHz + float sensorRange; // pre-evaluated from sensorRangeFormula (REQ-SHP-SENSOR) int level; std::string schematicId; diff --git a/src/lib/sim/ShipSystem.cpp b/src/lib/sim/ShipSystem.cpp index 432aafd..085cb1d 100644 --- a/src/lib/sim/ShipSystem.cpp +++ b/src/lib/sim/ShipSystem.cpp @@ -47,6 +47,7 @@ EntityId ShipSystem::spawn(const std::string& schematicId, int level, QVector2D ship.speedPerTick = static_cast( def->movement.speedFormula.evaluate(x)) / static_cast(kTickRateHz); + ship.sensorRange = static_cast(def->sensor.sensorRangeFormula.evaluate(x)); ship.level = level; ship.schematicId = schematicId; ship.isEnemy = isEnemy; @@ -61,9 +62,7 @@ EntityId ShipSystem::spawn(const std::string& schematicId, int level, QVector2D w.cooldownTicks = 0.0f; ship.weapon = w; - ThreatResponse tr; - tr.engagementRange = w.range; - ship.threatResponse = tr; + ship.threatResponse = ThreatResponse{}; if (!isEnemy) { @@ -241,7 +240,7 @@ void ShipSystem::tickThreatResponse(const BuildingSystem& buildings) continue; } - const float range = s.threatResponse->engagementRange; + const float range = s.sensorRange; if (!s.isEnemy) { @@ -394,7 +393,6 @@ void ShipSystem::tickThreatResponse(const BuildingSystem& buildings) 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) { @@ -410,7 +408,7 @@ void ShipSystem::tickRepairBehavior(BuildingSystem& buildings) for (const Ship& candidate : m_ships) { if (candidate.isEnemy - && (candidate.position - s.position).length() <= repairRange) + && (candidate.position - s.position).length() <= s.sensorRange) { enemyNearby = true; break; @@ -450,7 +448,7 @@ void ShipSystem::tickRepairBehavior(BuildingSystem& buildings) { s.repairBehavior->currentTarget = std::nullopt; currentId = kInvalidEntityId; - float bestDist = repairRange * kPatrolRange; + float bestDist = s.sensorRange; for (const Ship& candidate : m_ships) { @@ -640,8 +638,7 @@ void ShipSystem::tickScrapCollector(ScrapSystem& scraps, const BuildingSystem& b else { // Scan for nearest scrap within sensor range. - const float sensorRange = collectRange * 5.0f; - float bestDist = sensorRange; + float bestDist = s.sensorRange; std::optional bestPos; for (const Scrap& sc : scraps.allScraps()) { diff --git a/src/test/BehaviorSystemTest.cpp b/src/test/BehaviorSystemTest.cpp index 53b007d..c4e8aba 100644 --- a/src/test/BehaviorSystemTest.cpp +++ b/src/test/BehaviorSystemTest.cpp @@ -439,3 +439,132 @@ TEST_CASE("BehaviorSystem: full-cargo salvage ship moves toward SalvageBay", "[b REQUIRE(s->intent.priority == 1); REQUIRE(s->intent.target.x() < s->position.x()); } + +// --------------------------------------------------------------------------- +// Sensor range — spawn +// --------------------------------------------------------------------------- + +TEST_CASE("SensorRange: sensorRange is populated from config formula at spawn", "[sensor]") +{ + Fixture f; + // interceptor sensor_range_formula = "200" (test config); verify at level 1. + const EntityId id = f.ships.spawn("interceptor", 1, QVector2D(0.0f, 0.0f)); + REQUIRE(f.ships.findShip(id)->sensorRange == Approx(200.0f)); +} + +// --------------------------------------------------------------------------- +// Sensor range — tickThreatResponse +// --------------------------------------------------------------------------- + +TEST_CASE("SensorRange: player combat ship acquires enemy just inside sensor range", "[sensor]") +{ + Fixture f; + // interceptor sensor_range = 200 (test config); enemy at 190 tiles. + const EntityId playerId = f.ships.spawn("interceptor", 1, QVector2D(0.0f, 0.0f)); + const EntityId enemyId = f.ships.spawn("interceptor", 1, QVector2D(190.0f, 0.0f), + /*isEnemy=*/true); + + f.ships.clearMovementIntents(); + f.ships.tickThreatResponse(f.buildings); + + const Ship* player = f.ships.findShip(playerId); + REQUIRE(player->threatResponse->currentTarget == enemyId); +} + +TEST_CASE("SensorRange: player combat ship ignores enemy just outside sensor range", "[sensor]") +{ + Fixture f; + // interceptor sensor_range = 200 (test config); enemy at 210 tiles. + const EntityId playerId = f.ships.spawn("interceptor", 1, QVector2D(0.0f, 0.0f)); + f.ships.spawn("interceptor", 1, QVector2D(210.0f, 0.0f), /*isEnemy=*/true); + + f.ships.clearMovementIntents(); + f.ships.tickThreatResponse(f.buildings); + + const Ship* player = f.ships.findShip(playerId); + REQUIRE_FALSE(player->threatResponse->currentTarget.has_value()); +} + +TEST_CASE("SensorRange: enemy ship ignores player just outside sensor range", "[sensor]") +{ + Fixture f; + // interceptor sensor_range = 200 (test config); player at 210 tiles from enemy. + f.ships.spawn("interceptor", 1, QVector2D(0.0f, 0.0f)); + const EntityId enemyId = f.ships.spawn("interceptor", 1, QVector2D(210.0f, 0.0f), + /*isEnemy=*/true); + + f.ships.clearMovementIntents(); + f.ships.tickThreatResponse(f.buildings); + + const Ship* enemy = f.ships.findShip(enemyId); + REQUIRE_FALSE(enemy->threatResponse->currentTarget.has_value()); +} + +// --------------------------------------------------------------------------- +// Sensor range — tickRepairBehavior +// --------------------------------------------------------------------------- + +TEST_CASE("SensorRange: repair ship retreats from enemy within sensor range", "[sensor]") +{ + Fixture f; + // repair_ship sensor_range = 250; enemy at 200 tiles. + const EntityId repairId = f.ships.spawn("repair_ship", 1, QVector2D(0.0f, 0.0f)); + f.ships.spawn("interceptor", 1, QVector2D(200.0f, 0.0f), /*isEnemy=*/true); + + 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() < 0.0f); // retreating leftward +} + +TEST_CASE("SensorRange: repair ship does not retreat from enemy beyond sensor range", "[sensor]") +{ + Fixture f; + // repair_ship sensor_range = 250; enemy at 300 tiles. + const EntityId repairId = f.ships.spawn("repair_ship", 1, QVector2D(0.0f, 0.0f)); + f.ships.spawn("interceptor", 1, QVector2D(300.0f, 0.0f), /*isEnemy=*/true); + + f.ships.clearMovementIntents(); + f.ships.tickRepairBehavior(f.buildings); + + // Enemy outside sensor range → repair ship patrols rightward instead of retreating. + const Ship* repair = f.ships.findShip(repairId); + REQUIRE(repair->intent.target.x() > repair->position.x()); +} + +TEST_CASE("SensorRange: repair ship does not acquire damaged ally beyond sensor range", "[sensor]") +{ + Fixture f; + // repair_ship sensor_range = 250; damaged friendly at 300 tiles. + const EntityId repairId = f.ships.spawn("repair_ship", 1, QVector2D(0.0f, 0.0f)); + const EntityId friendlyId = f.ships.spawn("interceptor", 1, QVector2D(300.0f, 0.0f)); + f.ships.forEach([friendlyId](Ship& s) { + if (s.id == friendlyId) { s.hp = s.maxHp * 0.5f; } + }); + + f.ships.clearMovementIntents(); + f.ships.tickRepairBehavior(f.buildings); + + REQUIRE_FALSE(f.ships.findShip(repairId)->repairBehavior->currentTarget.has_value()); +} + +// --------------------------------------------------------------------------- +// Sensor range — tickScrapCollector +// --------------------------------------------------------------------------- + +TEST_CASE("SensorRange: salvage ship ignores scrap beyond sensor range", "[sensor]") +{ + Fixture f; + // salvage_ship sensor_range = 250; scrap at 300 tiles. + const EntityId shipId = f.ships.spawn("salvage_ship", 1, QVector2D(0.0f, 0.0f)); + f.scraps.spawn(QVector2D(300.0f, 0.0f), 1, 100000); + + f.ships.clearMovementIntents(); + f.ships.tickScrapCollector(f.scraps, f.buildings); + + const Ship* s = f.ships.findShip(shipId); + REQUIRE(s->scrapCollector->scrapTarget == std::nullopt); + REQUIRE(s->intent.target.x() > s->position.x()); // patrolling rightward +} diff --git a/src/test/ShipTest.cpp b/src/test/ShipTest.cpp index 88f6b65..ffd557d 100644 --- a/src/test/ShipTest.cpp +++ b/src/test/ShipTest.cpp @@ -58,8 +58,8 @@ TEST_CASE("ShipSystem: interceptor level 1 stats match config formulas", "[ship] 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)); + // sensor_range_formula = "200" + REQUIRE(ship->sensorRange == Approx(200.0f)); // cooldownTicks starts at 0 REQUIRE(ship->weapon->cooldownTicks == Approx(0.0f)); } diff --git a/src/test/config/ships.toml b/src/test/config/ships.toml index 08d33db..d67c2a6 100644 --- a/src/test/config/ships.toml +++ b/src/test/config/ships.toml @@ -16,6 +16,9 @@ hp_formula = "40 + 5*x" [ship.movement] speed_formula = "200 + 5*x" +[ship.sensor] +sensor_range_formula = "200" + [ship.combat] damage_formula = "10 + 2*x" attack_range_formula = "150" @@ -43,6 +46,9 @@ hp_formula = "120 + 15*x" [ship.movement] speed_formula = "120" +[ship.sensor] +sensor_range_formula = "300" + [ship.combat] damage_formula = "12 + 2*x" attack_range_formula = "250" @@ -70,6 +76,9 @@ hp_formula = "40 + 4*x" [ship.movement] speed_formula = "110" +[ship.sensor] +sensor_range_formula = "250" + [ship.salvage] collection_range = 50 cargo_capacity = 10 @@ -96,6 +105,9 @@ hp_formula = "60 + 5*x" [ship.movement] speed_formula = "130" +[ship.sensor] +sensor_range_formula = "250" + [ship.repair] repair_rate_formula = "5 + x" repair_range_formula = "80" diff --git a/src/ui/GameWorldView.cpp b/src/ui/GameWorldView.cpp index aac87da..bfd12f6 100644 --- a/src/ui/GameWorldView.cpp +++ b/src/ui/GameWorldView.cpp @@ -804,23 +804,7 @@ void GameWorldView::drawDebugSensorRanges(QPainter& painter) m_visuals->ships.find(role); if (it == m_visuals->ships.end()) { continue; } - float range = 0.0f; - if (ship.weapon.has_value()) - { - range = ship.weapon->range; - } - else if (ship.repairTool.has_value()) - { - range = ship.repairTool->range; - } - else if (ship.cargo.has_value()) - { - range = ship.cargo->collectionRange * 5.0f; - } - else - { - continue; - } + const float range = ship.sensorRange; const QPointF center = worldToWidget(ship.position); const qreal radiusPx = static_cast(range)