refactor AI system
This commit is contained in:
@@ -17,6 +17,8 @@
|
||||
#include "ModuleOwnerComponent.h"
|
||||
#include "MovementIntentSystem.h"
|
||||
#include "PositionComponent.h"
|
||||
#include "RepairSystem.h"
|
||||
#include "SalvagerSystem.h"
|
||||
#include "ScrapSystem.h"
|
||||
#include "ShipIdentityComponent.h"
|
||||
#include "ShipSystem.h"
|
||||
@@ -51,11 +53,16 @@ ArenaSimulation::ArenaSimulation(const GameConfig& gameConfig,
|
||||
m_rng);
|
||||
|
||||
m_shipSystem = std::make_unique<ShipSystem>(m_gameConfig, m_admin);
|
||||
// Arena fights are symmetric and aggressive: player-faction ships must not
|
||||
// retreat (REQ-BAL-SIM-AI). Only one faction would otherwise get retreat.
|
||||
m_shipSystem->setRetreatEnabled(false);
|
||||
m_aiSystem = std::make_unique<AiSystem>();
|
||||
m_movementIntentSystem = std::make_unique<MovementIntentSystem>();
|
||||
m_dynamicBodySystem = std::make_unique<DynamicBodySystem>();
|
||||
m_combatSystem = std::make_unique<CombatSystem>(m_gameConfig);
|
||||
m_scrapSystem = std::make_unique<ScrapSystem>(m_admin);
|
||||
m_salvagerSystem = std::make_unique<SalvagerSystem>(m_admin);
|
||||
m_repairSystem = std::make_unique<RepairSystem>(m_admin);
|
||||
|
||||
placeStructures();
|
||||
spawnShips();
|
||||
@@ -250,13 +257,11 @@ ArenaStatus ArenaSimulation::status() const
|
||||
|
||||
void ArenaSimulation::tick()
|
||||
{
|
||||
// Ship behavior systems (tick step 7).
|
||||
// Ship behavior systems (tick step 7): evaluate, select winner, execute.
|
||||
m_shipSystem->clearMovementIntents();
|
||||
m_aiSystem->tickHomeReturnBehavior(m_admin);
|
||||
m_aiSystem->tickThreatResponseBehavior(m_admin, *m_buildingSystem);
|
||||
m_aiSystem->tickRepairBehavior(m_admin, *m_buildingSystem);
|
||||
m_aiSystem->tickRepairTools(m_admin);
|
||||
m_aiSystem->tickSalvageBehavior(m_admin, *m_scrapSystem, *m_buildingSystem);
|
||||
m_aiSystem->tick(m_admin, *m_buildingSystem, *m_scrapSystem);
|
||||
m_salvagerSystem->tick(*m_scrapSystem, *m_buildingSystem);
|
||||
m_repairSystem->tick();
|
||||
|
||||
// Combat resolution (tick step 8).
|
||||
std::vector<WeaponFiredEvent> weaponFiredEvents;
|
||||
|
||||
@@ -22,6 +22,8 @@ class BuildingSystem;
|
||||
class CombatSystem;
|
||||
class DynamicBodySystem;
|
||||
class MovementIntentSystem;
|
||||
class RepairSystem;
|
||||
class SalvagerSystem;
|
||||
class ShipSystem;
|
||||
class ScrapSystem;
|
||||
|
||||
@@ -96,6 +98,8 @@ private:
|
||||
std::unique_ptr<DynamicBodySystem> m_dynamicBodySystem;
|
||||
std::unique_ptr<CombatSystem> m_combatSystem;
|
||||
std::unique_ptr<ScrapSystem> m_scrapSystem;
|
||||
std::unique_ptr<SalvagerSystem> m_salvagerSystem;
|
||||
std::unique_ptr<RepairSystem> m_repairSystem;
|
||||
|
||||
entt::entity m_team1HqEntity;
|
||||
entt::entity m_team2HqEntity;
|
||||
|
||||
9
src/lib/ecs/component/AdvanceBehavior.h
Normal file
9
src/lib/ecs/component/AdvanceBehavior.h
Normal file
@@ -0,0 +1,9 @@
|
||||
#pragma once
|
||||
|
||||
// Baseline fallback behavior, present on every ship. The executor moves the ship
|
||||
// toward the opposing side (direction derived from FactionComponent), so a ship
|
||||
// with no better behavior keeps advancing.
|
||||
struct AdvanceBehavior
|
||||
{
|
||||
float score = 0.0f;
|
||||
};
|
||||
13
src/lib/ecs/component/AttackBehavior.h
Normal file
13
src/lib/ecs/component/AttackBehavior.h
Normal file
@@ -0,0 +1,13 @@
|
||||
#pragma once
|
||||
|
||||
#include <optional>
|
||||
|
||||
#include "entt/entity/entity.hpp"
|
||||
|
||||
// Combat behavior for ships with weapons (was ThreatResponseBehaviorComponent).
|
||||
// The evaluator sets currentTarget; the executor pushes it to in-range weapons.
|
||||
struct AttackBehavior
|
||||
{
|
||||
std::optional<entt::entity> currentTarget;
|
||||
float score = 0.0f;
|
||||
};
|
||||
15
src/lib/ecs/component/BehaviorKind.h
Normal file
15
src/lib/ecs/component/BehaviorKind.h
Normal file
@@ -0,0 +1,15 @@
|
||||
#pragma once
|
||||
|
||||
// Identifies a ship behavior. Written into SelectedBehaviorComponent by the
|
||||
// AiSystem selection pass so each behavior's executor can tell whether it won.
|
||||
enum class BehaviorKind
|
||||
{
|
||||
None,
|
||||
Advance,
|
||||
Rally,
|
||||
Retreat,
|
||||
Attack,
|
||||
Repair,
|
||||
SalvageScrap,
|
||||
DeliverScrap
|
||||
};
|
||||
22
src/lib/ecs/component/BehaviorScores.h
Normal file
22
src/lib/ecs/component/BehaviorScores.h
Normal file
@@ -0,0 +1,22 @@
|
||||
#pragma once
|
||||
|
||||
// Score bands for ship-behavior evaluation. The AiSystem selection pass picks
|
||||
// the behavior with the highest score per ship; these constants define a single
|
||||
// comparable scale so the desired priority falls out:
|
||||
// Retreat > Attack > Repair / Salvage / Deliver > Rally > Advance.
|
||||
// Evaluators may return kInactive when their behavior does not apply this tick.
|
||||
namespace BehaviorScores
|
||||
{
|
||||
constexpr float kInactive = 0.0f;
|
||||
constexpr float kAdvance = 0.05f; // baseline fallback; always present
|
||||
constexpr float kRally = 0.20f;
|
||||
constexpr float kDeliver = 0.50f; // cargo full
|
||||
constexpr float kRepair = 0.55f;
|
||||
constexpr float kSalvage = 0.55f; // cargo not full and scrap in range
|
||||
constexpr float kAttack = 0.60f; // healthy and target in sensor range
|
||||
constexpr float kRetreat = 0.90f;
|
||||
|
||||
// Health fraction at/below which a ship is considered "low HP" — used by the
|
||||
// Attack evaluator (do not attack when low) and the Retreat evaluator.
|
||||
constexpr float kLowHpFraction = 0.3f;
|
||||
}
|
||||
@@ -1,24 +1,29 @@
|
||||
SET(HDRS
|
||||
${HDRS}
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/AdvanceBehavior.h
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/AttackBehavior.h
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/BehaviorKind.h
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/BehaviorScores.h
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/DeliverScrapBehavior.h
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/DespawnAtComponent.h
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/DynamicBodyComponent.h
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/FacingComponent.h
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/FactionComponent.h
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/HealthComponent.h
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/HomeReturnBehaviorComponent.h
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/HqProxyComponent.h
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/MovementIntentComponent.h
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/PositionComponent.h
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/RallyBehaviorComponent.h
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/RepairBehaviorComponent.h
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/RallyBehavior.h
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/RepairBehavior.h
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/RepairToolComponent.h
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/SalvageBehaviorComponent.h
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/RetreatBehavior.h
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/SalvageCargoComponent.h
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/SalvageScrapBehavior.h
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/ScrapDataComponent.h
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/SelectedBehaviorComponent.h
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/SensorRangeComponent.h
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/ShipIdentityComponent.h
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/StationBodyComponent.h
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/ThreatResponseBehaviorComponent.h
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/WeaponComponent.h
|
||||
PARENT_SCOPE
|
||||
)
|
||||
|
||||
12
src/lib/ecs/component/DeliverScrapBehavior.h
Normal file
12
src/lib/ecs/component/DeliverScrapBehavior.h
Normal file
@@ -0,0 +1,12 @@
|
||||
#pragma once
|
||||
|
||||
#include "BuildingId.h"
|
||||
|
||||
// Deliver-scrap behavior (one half of the old SalvageBehaviorComponent). Scored
|
||||
// high only when cargo is full. The evaluator assigns the nearest SalvageBay;
|
||||
// SalvagerSystem performs the actual delivery.
|
||||
struct DeliverScrapBehavior
|
||||
{
|
||||
BuildingId deliveryBay = kInvalidBuildingId;
|
||||
float score = 0.0f;
|
||||
};
|
||||
@@ -1,9 +0,0 @@
|
||||
#pragma once
|
||||
|
||||
#include <QVector2D>
|
||||
|
||||
struct HomeReturnBehaviorComponent
|
||||
{
|
||||
float retreatHpFraction;
|
||||
QVector2D homePos;
|
||||
};
|
||||
@@ -2,11 +2,12 @@
|
||||
|
||||
#include <QVector2D>
|
||||
|
||||
// A ship-behavior system writes this each tick before movement runs; the
|
||||
// highest-priority write wins. Priority order is fixed globally — see
|
||||
// architecture.md "Movement Arbitration".
|
||||
// The winning behavior's executor writes this each tick before movement runs.
|
||||
// `active` is false when no behavior set a destination (the ship brakes); the
|
||||
// score-based selection (see architecture.md "Movement Arbitration") decides
|
||||
// which single executor writes here.
|
||||
struct MovementIntentComponent
|
||||
{
|
||||
int priority;
|
||||
bool active = false;
|
||||
QVector2D target;
|
||||
};
|
||||
|
||||
11
src/lib/ecs/component/RallyBehavior.h
Normal file
11
src/lib/ecs/component/RallyBehavior.h
Normal file
@@ -0,0 +1,11 @@
|
||||
#pragma once
|
||||
|
||||
#include <QVector2D>
|
||||
|
||||
// Player combat ships loiter at the rally point until the departure timer
|
||||
// removes this component (ShipSystem::triggerRallyDeparture).
|
||||
struct RallyBehavior
|
||||
{
|
||||
QVector2D rallyPoint;
|
||||
float score = 0.0f;
|
||||
};
|
||||
@@ -1,8 +0,0 @@
|
||||
#pragma once
|
||||
|
||||
#include <QVector2D>
|
||||
|
||||
struct RallyBehaviorComponent
|
||||
{
|
||||
QVector2D rallyPoint;
|
||||
};
|
||||
15
src/lib/ecs/component/RepairBehavior.h
Normal file
15
src/lib/ecs/component/RepairBehavior.h
Normal file
@@ -0,0 +1,15 @@
|
||||
#pragma once
|
||||
|
||||
#include <optional>
|
||||
|
||||
#include "entt/entity/entity.hpp"
|
||||
|
||||
// Repair behavior for ships with repair modules. The evaluator picks the nearest
|
||||
// damaged friendly as currentTarget; the executor moves toward it and assigns
|
||||
// in-range repair tools. RepairSystem applies the actual healing.
|
||||
struct RepairBehavior
|
||||
{
|
||||
std::optional<entt::entity> currentTarget;
|
||||
float maxRepairRange_tiles = 0.0f;
|
||||
float score = 0.0f;
|
||||
};
|
||||
@@ -1,11 +0,0 @@
|
||||
#pragma once
|
||||
|
||||
#include <optional>
|
||||
|
||||
#include "entt/entity/entity.hpp"
|
||||
|
||||
struct RepairBehaviorComponent
|
||||
{
|
||||
std::optional<entt::entity> currentTarget;
|
||||
float maxRepairRange_tiles = 0.0f;
|
||||
};
|
||||
13
src/lib/ecs/component/RetreatBehavior.h
Normal file
13
src/lib/ecs/component/RetreatBehavior.h
Normal file
@@ -0,0 +1,13 @@
|
||||
#pragma once
|
||||
|
||||
#include <QVector2D>
|
||||
|
||||
// Player-only retreat behavior (replaces HomeReturnBehaviorComponent). Scored
|
||||
// high when HP is low, or when an enemy is in sensor range and the ship cannot
|
||||
// fight back. The executor moves the ship to retreatPoint (the rally point).
|
||||
struct RetreatBehavior
|
||||
{
|
||||
float retreatHpFraction = 0.0f;
|
||||
QVector2D retreatPoint;
|
||||
float score = 0.0f;
|
||||
};
|
||||
@@ -1,14 +0,0 @@
|
||||
#pragma once
|
||||
|
||||
#include <optional>
|
||||
|
||||
#include <QVector2D>
|
||||
|
||||
#include "BuildingId.h"
|
||||
|
||||
struct SalvageBehaviorComponent
|
||||
{
|
||||
std::optional<QVector2D> scrapTarget;
|
||||
BuildingId deliveryBay; // kInvalidBuildingId until assigned at a salvage bay
|
||||
float maxCollectionRange_tiles = 0.0f;
|
||||
};
|
||||
14
src/lib/ecs/component/SalvageScrapBehavior.h
Normal file
14
src/lib/ecs/component/SalvageScrapBehavior.h
Normal file
@@ -0,0 +1,14 @@
|
||||
#pragma once
|
||||
|
||||
#include <optional>
|
||||
|
||||
#include <QVector2D>
|
||||
|
||||
// Collect-scrap behavior (one half of the old SalvageBehaviorComponent). The
|
||||
// evaluator finds the nearest scrap and sets scrapTarget when cargo is not full.
|
||||
struct SalvageScrapBehavior
|
||||
{
|
||||
std::optional<QVector2D> scrapTarget;
|
||||
float maxCollectionRange_tiles = 0.0f;
|
||||
float score = 0.0f;
|
||||
};
|
||||
11
src/lib/ecs/component/SelectedBehaviorComponent.h
Normal file
11
src/lib/ecs/component/SelectedBehaviorComponent.h
Normal file
@@ -0,0 +1,11 @@
|
||||
#pragma once
|
||||
|
||||
#include "BehaviorKind.h"
|
||||
|
||||
// Result of the AiSystem selection pass: the highest-scoring behavior for a
|
||||
// ship this tick. Each behavior's executor acts only when it is the winner.
|
||||
struct SelectedBehaviorComponent
|
||||
{
|
||||
BehaviorKind winner = BehaviorKind::None;
|
||||
float bestScore = 0.0f;
|
||||
};
|
||||
@@ -1,10 +0,0 @@
|
||||
#pragma once
|
||||
|
||||
#include <optional>
|
||||
|
||||
#include "entt/entity/entity.hpp"
|
||||
|
||||
struct ThreatResponseBehaviorComponent
|
||||
{
|
||||
std::optional<entt::entity> currentTarget;
|
||||
};
|
||||
@@ -1,587 +1,82 @@
|
||||
#include "AiSystem.h"
|
||||
|
||||
#include <optional>
|
||||
#include <unordered_map>
|
||||
#include <vector>
|
||||
#include <limits>
|
||||
|
||||
#include <QVector2D>
|
||||
|
||||
#include "Building.h"
|
||||
#include "BuildingSystem.h"
|
||||
#include "BuildingType.h"
|
||||
#include "BuildingId.h"
|
||||
#include "AdvanceBehavior.h"
|
||||
#include "AttackBehavior.h"
|
||||
#include "BehaviorKind.h"
|
||||
#include "DeliverScrapBehavior.h"
|
||||
#include "EntityAdmin.h"
|
||||
#include "FactionComponent.h"
|
||||
#include "HealthComponent.h"
|
||||
#include "HomeReturnBehaviorComponent.h"
|
||||
#include "HqProxyComponent.h"
|
||||
#include "ModuleOwnerComponent.h"
|
||||
#include "MovementIntentComponent.h"
|
||||
#include "PositionComponent.h"
|
||||
#include "RallyBehaviorComponent.h"
|
||||
#include "RepairBehaviorComponent.h"
|
||||
#include "RepairToolComponent.h"
|
||||
#include "SalvageBehaviorComponent.h"
|
||||
#include "SalvageCargoComponent.h"
|
||||
#include "ScrapSystem.h"
|
||||
#include "SensorRangeComponent.h"
|
||||
#include "ShipIdentityComponent.h"
|
||||
#include "StationBodyComponent.h"
|
||||
#include "ThreatResponseBehaviorComponent.h"
|
||||
#include "RallyBehavior.h"
|
||||
#include "RepairBehavior.h"
|
||||
#include "RetreatBehavior.h"
|
||||
#include "SalvageScrapBehavior.h"
|
||||
#include "SelectedBehaviorComponent.h"
|
||||
#include "tracing.h"
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Shared helpers for repair targeting
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
struct RepairableInfo
|
||||
namespace
|
||||
{
|
||||
entt::entity entity;
|
||||
QVector2D position;
|
||||
bool isEnemy;
|
||||
bool isShip;
|
||||
float hp;
|
||||
float maxHp;
|
||||
};
|
||||
|
||||
static std::vector<RepairableInfo> buildRepairables(EntityAdmin& admin)
|
||||
{
|
||||
std::vector<RepairableInfo> repairables;
|
||||
|
||||
admin.forEach<ShipIdentityComponent, PositionComponent, FactionComponent, HealthComponent>(
|
||||
[&repairables](entt::entity e, const ShipIdentityComponent& /*si*/,
|
||||
const PositionComponent& pos, const FactionComponent& f,
|
||||
const HealthComponent& h)
|
||||
{
|
||||
repairables.push_back({e, pos.value, f.isEnemy, true, h.hp, h.maxHp});
|
||||
});
|
||||
|
||||
admin.forEach<StationBodyComponent, PositionComponent, FactionComponent, HealthComponent>(
|
||||
[&repairables](entt::entity e, const StationBodyComponent& /*sb*/,
|
||||
const PositionComponent& pos, const FactionComponent& f,
|
||||
const HealthComponent& h)
|
||||
{
|
||||
repairables.push_back({e, pos.value, f.isEnemy, false, h.hp, h.maxHp});
|
||||
});
|
||||
|
||||
return repairables;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// tickHomeReturnBehavior (priority 4)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
void AiSystem::tickHomeReturnBehavior(EntityAdmin& admin)
|
||||
{
|
||||
TRACE();
|
||||
admin.forEach<HomeReturnBehaviorComponent, HealthComponent, MovementIntentComponent>(
|
||||
[](entt::entity /*e*/, const HomeReturnBehaviorComponent& homeReturnBehavior,
|
||||
const HealthComponent& h, MovementIntentComponent& intent)
|
||||
{
|
||||
if (h.hp / h.maxHp < homeReturnBehavior.retreatHpFraction)
|
||||
{
|
||||
if (4 > intent.priority)
|
||||
{
|
||||
intent = MovementIntentComponent{4, homeReturnBehavior.homePos};
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// tickThreatResponseBehavior (priority 3)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
void AiSystem::tickThreatResponseBehavior(EntityAdmin& admin, const BuildingSystem& buildings)
|
||||
{
|
||||
TRACE();
|
||||
// Snapshot all combatant entities for target acquisition.
|
||||
struct CombatantInfo
|
||||
// Records a behavior's score for its owning ship, keeping the highest seen.
|
||||
// Considered high-priority first, so strict '>' breaks ties toward priority.
|
||||
template <typename Behavior>
|
||||
void consider(EntityAdmin& admin, BehaviorKind kind)
|
||||
{
|
||||
entt::entity entity;
|
||||
QVector2D position;
|
||||
bool isEnemy;
|
||||
bool isStation;
|
||||
};
|
||||
std::vector<CombatantInfo> combatants;
|
||||
|
||||
admin.forEach<PositionComponent, FactionComponent, ShipIdentityComponent>(
|
||||
[&combatants](entt::entity e, const PositionComponent& pos,
|
||||
const FactionComponent& f, const ShipIdentityComponent& /*si*/)
|
||||
{
|
||||
combatants.push_back({e, pos.value, f.isEnemy, false});
|
||||
});
|
||||
|
||||
admin.forEach<PositionComponent, FactionComponent, StationBodyComponent>(
|
||||
[&combatants](entt::entity e, const PositionComponent& pos,
|
||||
const FactionComponent& f, const StationBodyComponent& /*sb*/)
|
||||
{
|
||||
combatants.push_back({e, pos.value, f.isEnemy, true});
|
||||
});
|
||||
|
||||
admin.forEach<PositionComponent, FactionComponent, HqProxyComponent>(
|
||||
[&combatants](entt::entity e, const PositionComponent& pos,
|
||||
const FactionComponent& f, const HqProxyComponent& /*hq*/)
|
||||
{
|
||||
combatants.push_back({e, pos.value, f.isEnemy, true});
|
||||
});
|
||||
|
||||
admin.forEach<ThreatResponseBehaviorComponent, PositionComponent, FactionComponent,
|
||||
SensorRangeComponent, MovementIntentComponent>(
|
||||
[&](entt::entity e, ThreatResponseBehaviorComponent& threatResponseBehavior,
|
||||
PositionComponent& pos, FactionComponent& faction,
|
||||
SensorRangeComponent& sensor, MovementIntentComponent& intent)
|
||||
{
|
||||
const float range = sensor.value_tiles;
|
||||
|
||||
// Validate current target.
|
||||
bool targetValid = false;
|
||||
if (threatResponseBehavior.currentTarget)
|
||||
admin.forEach<Behavior, SelectedBehaviorComponent>(
|
||||
[kind](entt::entity /*e*/, const Behavior& behavior,
|
||||
SelectedBehaviorComponent& selected)
|
||||
{
|
||||
const entt::entity t = *threatResponseBehavior.currentTarget;
|
||||
if (admin.isValid(t) && admin.hasAll<PositionComponent>(t))
|
||||
if (behavior.score > selected.bestScore)
|
||||
{
|
||||
const float dist =
|
||||
(admin.get<PositionComponent>(t).value - pos.value).length();
|
||||
if (dist <= range)
|
||||
{
|
||||
targetValid = true;
|
||||
}
|
||||
selected.bestScore = behavior.score;
|
||||
selected.winner = kind;
|
||||
}
|
||||
}
|
||||
|
||||
if (!targetValid)
|
||||
{
|
||||
threatResponseBehavior.currentTarget = std::nullopt;
|
||||
float bestDist = range;
|
||||
|
||||
for (const CombatantInfo& c : combatants)
|
||||
{
|
||||
if (c.entity == e) { continue; }
|
||||
|
||||
bool isValidTarget = false;
|
||||
if (!faction.isEnemy)
|
||||
{
|
||||
isValidTarget = c.isEnemy;
|
||||
}
|
||||
else
|
||||
{
|
||||
isValidTarget = !c.isEnemy;
|
||||
}
|
||||
if (!isValidTarget) { continue; }
|
||||
|
||||
const float dist = (c.position - pos.value).length();
|
||||
if (dist < bestDist)
|
||||
{
|
||||
bestDist = dist;
|
||||
threatResponseBehavior.currentTarget = c.entity;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (threatResponseBehavior.currentTarget)
|
||||
{
|
||||
const entt::entity t = *threatResponseBehavior.currentTarget;
|
||||
QVector2D dest = pos.value;
|
||||
if (admin.isValid(t) && admin.hasAll<PositionComponent>(t))
|
||||
{
|
||||
dest = admin.get<PositionComponent>(t).value;
|
||||
}
|
||||
if (3 > intent.priority)
|
||||
{
|
||||
intent = MovementIntentComponent{3, dest};
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
if (3 > intent.priority)
|
||||
{
|
||||
if (admin.hasAll<RallyBehaviorComponent>(e))
|
||||
{
|
||||
intent = MovementIntentComponent{
|
||||
3, admin.get<RallyBehaviorComponent>(e).rallyPoint};
|
||||
}
|
||||
else if (!faction.isEnemy)
|
||||
{
|
||||
intent = MovementIntentComponent{
|
||||
3, QVector2D(pos.value.x() + 1000.0f, pos.value.y())};
|
||||
}
|
||||
else
|
||||
{
|
||||
intent = MovementIntentComponent{
|
||||
3, QVector2D(-10000.0f, pos.value.y())};
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// tickRepairBehavior (priority 2)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
void AiSystem::tickRepairBehavior(EntityAdmin& admin, BuildingSystem& buildings)
|
||||
void AiSystem::tick(EntityAdmin& admin, const BuildingSystem& buildings,
|
||||
const ScrapSystem& scraps)
|
||||
{
|
||||
TRACE();
|
||||
std::vector<RepairableInfo> repairables = buildRepairables(admin);
|
||||
TRACE();
|
||||
|
||||
// Snapshot enemy ships for threat detection.
|
||||
struct EnemyInfo
|
||||
{
|
||||
QVector2D position;
|
||||
};
|
||||
std::vector<EnemyInfo> enemies;
|
||||
admin.forEach<ShipIdentityComponent, PositionComponent, FactionComponent>(
|
||||
[&enemies](entt::entity /*e*/, const ShipIdentityComponent& /*si*/,
|
||||
const PositionComponent& pos, const FactionComponent& f)
|
||||
{
|
||||
if (f.isEnemy)
|
||||
{
|
||||
enemies.push_back({pos.value});
|
||||
}
|
||||
});
|
||||
// Phase 1: evaluators score behaviors and set their target data.
|
||||
m_advanceEvaluator.evaluate(admin);
|
||||
m_rallyEvaluator.evaluate(admin);
|
||||
m_retreatEvaluator.evaluate(admin);
|
||||
m_attackEvaluator.evaluate(admin);
|
||||
m_repairEvaluator.evaluate(admin);
|
||||
m_salvageScrapEvaluator.evaluate(admin, scraps);
|
||||
m_deliverScrapEvaluator.evaluate(admin, buildings);
|
||||
|
||||
admin.forEach<RepairBehaviorComponent, PositionComponent,
|
||||
FactionComponent, SensorRangeComponent, MovementIntentComponent>(
|
||||
[&](entt::entity e, RepairBehaviorComponent& rb,
|
||||
PositionComponent& pos, FactionComponent& /*faction*/,
|
||||
SensorRangeComponent& sensor, MovementIntentComponent& intent)
|
||||
{
|
||||
// Flee if enemy nearby.
|
||||
bool enemyNearby = false;
|
||||
for (const EnemyInfo& enemy : enemies)
|
||||
{
|
||||
if ((enemy.position - pos.value).length() <= sensor.value_tiles)
|
||||
{
|
||||
enemyNearby = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (enemyNearby)
|
||||
{
|
||||
if (2 > intent.priority)
|
||||
{
|
||||
intent = MovementIntentComponent{
|
||||
2, QVector2D(-10000.0f, pos.value.y())};
|
||||
}
|
||||
return;
|
||||
}
|
||||
// Phase 2: pick the highest-scoring behavior per ship.
|
||||
selectWinningBehaviors(admin);
|
||||
|
||||
// Validate current target.
|
||||
bool targetValid = false;
|
||||
if (rb.currentTarget)
|
||||
{
|
||||
const entt::entity t = *rb.currentTarget;
|
||||
if (admin.isValid(t) && admin.hasAll<HealthComponent>(t))
|
||||
{
|
||||
const HealthComponent& th = admin.get<HealthComponent>(t);
|
||||
if (th.hp > 0.0f && th.hp < th.maxHp)
|
||||
{
|
||||
targetValid = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!targetValid)
|
||||
{
|
||||
rb.currentTarget = std::nullopt;
|
||||
float bestDist = sensor.value_tiles;
|
||||
|
||||
for (const RepairableInfo& r : repairables)
|
||||
{
|
||||
if (r.entity == e) { continue; }
|
||||
if (r.isEnemy) { continue; }
|
||||
if (r.hp >= r.maxHp) { continue; }
|
||||
const float dist = (r.position - pos.value).length();
|
||||
if (dist < bestDist)
|
||||
{
|
||||
bestDist = dist;
|
||||
rb.currentTarget = r.entity;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!rb.currentTarget)
|
||||
{
|
||||
if (2 > intent.priority)
|
||||
{
|
||||
intent = MovementIntentComponent{
|
||||
2, QVector2D(pos.value.x() + 1000.0f, pos.value.y())};
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const entt::entity target = *rb.currentTarget;
|
||||
QVector2D targetPos = pos.value;
|
||||
if (admin.isValid(target) && admin.hasAll<PositionComponent>(target))
|
||||
{
|
||||
targetPos = admin.get<PositionComponent>(target).value;
|
||||
}
|
||||
|
||||
if (2 > intent.priority)
|
||||
{
|
||||
intent = MovementIntentComponent{2, targetPos};
|
||||
}
|
||||
});
|
||||
// Phase 3: executors run for the winning behavior.
|
||||
m_advanceExecutor.execute(admin);
|
||||
m_rallyExecutor.execute(admin);
|
||||
m_retreatExecutor.execute(admin);
|
||||
m_attackExecutor.execute(admin);
|
||||
m_repairExecutor.execute(admin);
|
||||
m_salvageScrapExecutor.execute(admin);
|
||||
m_deliverScrapExecutor.execute(admin, buildings);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// tickRepairTools
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
void AiSystem::tickRepairTools(EntityAdmin& admin)
|
||||
void AiSystem::selectWinningBehaviors(EntityAdmin& admin)
|
||||
{
|
||||
TRACE();
|
||||
const std::vector<RepairableInfo> repairables = buildRepairables(admin);
|
||||
|
||||
admin.forEach<RepairToolComponent, ModuleOwnerComponent>(
|
||||
[&](entt::entity /*e*/, RepairToolComponent& rt, const ModuleOwnerComponent& owner)
|
||||
TRACE();
|
||||
admin.forEach<SelectedBehaviorComponent>(
|
||||
[](entt::entity /*e*/, SelectedBehaviorComponent& selected)
|
||||
{
|
||||
if (!admin.hasAll<RepairBehaviorComponent>(owner.owner)) { return; }
|
||||
|
||||
const RepairBehaviorComponent& rb =
|
||||
admin.get<RepairBehaviorComponent>(owner.owner);
|
||||
const PositionComponent& ownerPos =
|
||||
admin.get<PositionComponent>(owner.owner);
|
||||
|
||||
// Try the ship's preferred nav target first.
|
||||
if (rb.currentTarget)
|
||||
{
|
||||
const entt::entity preferred = *rb.currentTarget;
|
||||
if (admin.isValid(preferred) && admin.hasAll<HealthComponent>(preferred)
|
||||
&& admin.hasAll<PositionComponent>(preferred))
|
||||
{
|
||||
HealthComponent& th = admin.get<HealthComponent>(preferred);
|
||||
const float dist =
|
||||
(admin.get<PositionComponent>(preferred).value
|
||||
- ownerPos.value).length();
|
||||
if (th.hp > 0.0f && th.hp < th.maxHp && dist <= rt.range_tiles)
|
||||
{
|
||||
rt.currentTarget = rb.currentTarget;
|
||||
th.hp = std::min(th.hp + rt.ratePerTick, th.maxHp);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Preferred target unavailable; scan for nearest damaged friendly in range.
|
||||
rt.currentTarget = std::nullopt;
|
||||
float bestDist = rt.range_tiles;
|
||||
for (const RepairableInfo& r : repairables)
|
||||
{
|
||||
if (r.isEnemy) { continue; }
|
||||
if (r.hp <= 0.0f || r.hp >= r.maxHp) { continue; }
|
||||
const float dist = (r.position - ownerPos.value).length();
|
||||
if (dist < bestDist)
|
||||
{
|
||||
bestDist = dist;
|
||||
rt.currentTarget = r.entity;
|
||||
}
|
||||
}
|
||||
|
||||
if (!rt.currentTarget) { return; }
|
||||
|
||||
HealthComponent& targetHealth =
|
||||
admin.get<HealthComponent>(*rt.currentTarget);
|
||||
targetHealth.hp = std::min(targetHealth.hp + rt.ratePerTick, targetHealth.maxHp);
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// tickSalvageBehavior (priority 1)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
void AiSystem::tickSalvageBehavior(EntityAdmin& admin, ScrapSystem& scraps,
|
||||
BuildingSystem& buildings)
|
||||
{
|
||||
TRACE();
|
||||
// Snapshot enemy ships for threat detection.
|
||||
struct EnemyShipPos
|
||||
{
|
||||
QVector2D position;
|
||||
};
|
||||
std::vector<EnemyShipPos> enemyShips;
|
||||
admin.forEach<ShipIdentityComponent, PositionComponent, FactionComponent>(
|
||||
[&enemyShips](entt::entity /*e*/, const ShipIdentityComponent& /*si*/,
|
||||
const PositionComponent& pos, const FactionComponent& f)
|
||||
{
|
||||
if (f.isEnemy)
|
||||
{
|
||||
enemyShips.push_back({pos.value});
|
||||
}
|
||||
});
|
||||
|
||||
// Aggregate cargo across all salvage-module children per owning ship.
|
||||
struct AggregatedCargo
|
||||
{
|
||||
int totalCurrent = 0;
|
||||
int totalCapacity = 0;
|
||||
};
|
||||
std::unordered_map<entt::entity, AggregatedCargo> cargoByShip;
|
||||
admin.forEach<SalvageCargoComponent, ModuleOwnerComponent>(
|
||||
[&](entt::entity /*ce*/, const SalvageCargoComponent& c, const ModuleOwnerComponent& o)
|
||||
{
|
||||
AggregatedCargo& agg = cargoByShip[o.owner];
|
||||
agg.totalCurrent += c.current;
|
||||
agg.totalCapacity += c.capacity;
|
||||
});
|
||||
|
||||
const std::vector<ScrapInfo> allScrap = scraps.allScrapInfo();
|
||||
|
||||
// Tick down per-module collection cooldowns.
|
||||
admin.forEach<SalvageCargoComponent>(
|
||||
[](entt::entity /*e*/, SalvageCargoComponent& c)
|
||||
{
|
||||
if (c.cooldownTicksRemaining > 0) { --c.cooldownTicksRemaining; }
|
||||
});
|
||||
|
||||
admin.forEach<SalvageBehaviorComponent, PositionComponent,
|
||||
SensorRangeComponent, MovementIntentComponent>(
|
||||
[&](entt::entity e, SalvageBehaviorComponent& salvageBehavior,
|
||||
PositionComponent& pos,
|
||||
SensorRangeComponent& sensor, MovementIntentComponent& intent)
|
||||
{
|
||||
const float collectRange = salvageBehavior.maxCollectionRange_tiles;
|
||||
const AggregatedCargo& cargoState = cargoByShip[e];
|
||||
|
||||
// Assign nearest SalvageBay if needed.
|
||||
if (salvageBehavior.deliveryBay == kInvalidBuildingId)
|
||||
{
|
||||
const Building* bay = buildings.findNearestBuilding(pos.value,
|
||||
BuildingType::SalvageBay);
|
||||
if (bay)
|
||||
{
|
||||
salvageBehavior.deliveryBay = bay->id;
|
||||
}
|
||||
}
|
||||
|
||||
const BuildingId bayId = salvageBehavior.deliveryBay;
|
||||
|
||||
QVector2D bayPos = pos.value;
|
||||
if (bayId != kInvalidBuildingId)
|
||||
{
|
||||
const Building* bay = buildings.findBuilding(bayId);
|
||||
if (bay)
|
||||
{
|
||||
bayPos = QVector2D(bay->anchor.x() + bay->footprint.width() / 2.0f,
|
||||
bay->anchor.y() + bay->footprint.height() / 2.0f);
|
||||
}
|
||||
}
|
||||
|
||||
const bool cargoFull = (cargoState.totalCurrent >= cargoState.totalCapacity
|
||||
&& cargoState.totalCapacity > 0);
|
||||
|
||||
if (cargoFull)
|
||||
{
|
||||
if (1 > intent.priority)
|
||||
{
|
||||
intent = MovementIntentComponent{1, bayPos};
|
||||
}
|
||||
if (bayId != kInvalidBuildingId
|
||||
&& (pos.value - bayPos).length() <= 1.0f)
|
||||
{
|
||||
// Decrement first non-empty salvage child.
|
||||
bool delivered = false;
|
||||
admin.forEach<SalvageCargoComponent, ModuleOwnerComponent>(
|
||||
[&](entt::entity /*ce*/, SalvageCargoComponent& c,
|
||||
const ModuleOwnerComponent& o)
|
||||
{
|
||||
if (delivered || o.owner != e || c.current <= 0) { return; }
|
||||
if (buildings.deliverScrapToSalvageBay(bayId))
|
||||
{
|
||||
--c.current;
|
||||
delivered = true;
|
||||
}
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Retreat if enemy near and cargo empty.
|
||||
bool retreating = false;
|
||||
if (cargoState.totalCurrent == 0)
|
||||
{
|
||||
for (const EnemyShipPos& enemy : enemyShips)
|
||||
{
|
||||
if ((enemy.position - pos.value).length() <= collectRange)
|
||||
{
|
||||
if (1 > intent.priority)
|
||||
{
|
||||
intent = MovementIntentComponent{
|
||||
1, QVector2D(-10000.0f, pos.value.y())};
|
||||
}
|
||||
retreating = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (retreating) { return; }
|
||||
|
||||
// Per-module independent collection: each ready module collects one scrap.
|
||||
bool anythingCollected = false;
|
||||
admin.forEach<SalvageCargoComponent, ModuleOwnerComponent>(
|
||||
[&](entt::entity /*ce*/, SalvageCargoComponent& c,
|
||||
const ModuleOwnerComponent& o)
|
||||
{
|
||||
if (o.owner != e || c.current >= c.capacity
|
||||
|| c.cooldownTicksRemaining > 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
for (const ScrapInfo& si : allScrap)
|
||||
{
|
||||
if ((si.position - pos.value).length() > c.collectionRange_tiles) { continue; }
|
||||
if (scraps.consume(si.entity))
|
||||
{
|
||||
++c.current;
|
||||
c.cooldownTicksRemaining = c.collectionIntervalTicks;
|
||||
anythingCollected = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
if (anythingCollected)
|
||||
{
|
||||
salvageBehavior.scrapTarget = std::nullopt;
|
||||
}
|
||||
|
||||
// Move toward scrap target or find a new one.
|
||||
if (salvageBehavior.scrapTarget)
|
||||
{
|
||||
if (1 > intent.priority)
|
||||
{
|
||||
intent = MovementIntentComponent{1, *salvageBehavior.scrapTarget};
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
float bestDist = sensor.value_tiles;
|
||||
std::optional<QVector2D> bestPos;
|
||||
for (const ScrapInfo& si : allScrap)
|
||||
{
|
||||
const float dist = (si.position - pos.value).length();
|
||||
if (dist < bestDist)
|
||||
{
|
||||
bestDist = dist;
|
||||
bestPos = si.position;
|
||||
}
|
||||
}
|
||||
if (bestPos)
|
||||
{
|
||||
salvageBehavior.scrapTarget = bestPos;
|
||||
if (1 > intent.priority)
|
||||
{
|
||||
intent = MovementIntentComponent{1, *bestPos};
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
if (1 > intent.priority)
|
||||
{
|
||||
intent = MovementIntentComponent{
|
||||
1, QVector2D(pos.value.x() + 1000.0f, pos.value.y())};
|
||||
}
|
||||
}
|
||||
}
|
||||
selected.winner = BehaviorKind::None;
|
||||
selected.bestScore = std::numeric_limits<float>::lowest();
|
||||
});
|
||||
|
||||
// Highest priority first so ties resolve toward the more urgent behavior.
|
||||
consider<RetreatBehavior>(admin, BehaviorKind::Retreat);
|
||||
consider<AttackBehavior>(admin, BehaviorKind::Attack);
|
||||
consider<RepairBehavior>(admin, BehaviorKind::Repair);
|
||||
consider<SalvageScrapBehavior>(admin, BehaviorKind::SalvageScrap);
|
||||
consider<DeliverScrapBehavior>(admin, BehaviorKind::DeliverScrap);
|
||||
consider<RallyBehavior>(admin, BehaviorKind::Rally);
|
||||
consider<AdvanceBehavior>(admin, BehaviorKind::Advance);
|
||||
}
|
||||
|
||||
@@ -1,15 +1,52 @@
|
||||
#pragma once
|
||||
|
||||
#include "AdvanceEvaluator.h"
|
||||
#include "AdvanceExecutor.h"
|
||||
#include "AttackEvaluator.h"
|
||||
#include "AttackExecutor.h"
|
||||
#include "DeliverScrapEvaluator.h"
|
||||
#include "DeliverScrapExecutor.h"
|
||||
#include "RallyEvaluator.h"
|
||||
#include "RallyExecutor.h"
|
||||
#include "RepairEvaluator.h"
|
||||
#include "RepairExecutor.h"
|
||||
#include "RetreatEvaluator.h"
|
||||
#include "RetreatExecutor.h"
|
||||
#include "SalvageScrapEvaluator.h"
|
||||
#include "SalvageScrapExecutor.h"
|
||||
|
||||
class BuildingSystem;
|
||||
class EntityAdmin;
|
||||
class ScrapSystem;
|
||||
|
||||
// Orchestrates ship-behavior decision-making in three batched phases:
|
||||
// 1. evaluators score each behavior and set its target data,
|
||||
// 2. selectWinningBehaviors picks the highest-scoring behavior per ship,
|
||||
// 3. executors run for the winning behavior, setting movement intent and
|
||||
// preferred module targets.
|
||||
// All world mutation (collection, healing, damage) is left to the module
|
||||
// systems (SalvagerSystem, RepairSystem, CombatSystem).
|
||||
class AiSystem
|
||||
{
|
||||
public:
|
||||
void tickHomeReturnBehavior(EntityAdmin& admin);
|
||||
void tickThreatResponseBehavior(EntityAdmin& admin, const BuildingSystem& buildings);
|
||||
void tickRepairBehavior(EntityAdmin& admin, BuildingSystem& buildings);
|
||||
void tickRepairTools(EntityAdmin& admin);
|
||||
void tickSalvageBehavior(EntityAdmin& admin, ScrapSystem& scraps, BuildingSystem& buildings);
|
||||
void tick(EntityAdmin& admin, const BuildingSystem& buildings, const ScrapSystem& scraps);
|
||||
|
||||
private:
|
||||
void selectWinningBehaviors(EntityAdmin& admin);
|
||||
|
||||
AdvanceEvaluator m_advanceEvaluator;
|
||||
RallyEvaluator m_rallyEvaluator;
|
||||
RetreatEvaluator m_retreatEvaluator;
|
||||
AttackEvaluator m_attackEvaluator;
|
||||
RepairEvaluator m_repairEvaluator;
|
||||
SalvageScrapEvaluator m_salvageScrapEvaluator;
|
||||
DeliverScrapEvaluator m_deliverScrapEvaluator;
|
||||
|
||||
AdvanceExecutor m_advanceExecutor;
|
||||
RallyExecutor m_rallyExecutor;
|
||||
RetreatExecutor m_retreatExecutor;
|
||||
AttackExecutor m_attackExecutor;
|
||||
RepairExecutor m_repairExecutor;
|
||||
SalvageScrapExecutor m_salvageScrapExecutor;
|
||||
DeliverScrapExecutor m_deliverScrapExecutor;
|
||||
};
|
||||
|
||||
@@ -1,9 +1,26 @@
|
||||
SET(HDRS
|
||||
${HDRS}
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/ai/AdvanceEvaluator.h
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/ai/AdvanceExecutor.h
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/ai/AttackEvaluator.h
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/ai/AttackExecutor.h
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/ai/BehaviorTargeting.h
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/ai/DeliverScrapEvaluator.h
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/ai/DeliverScrapExecutor.h
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/ai/RallyEvaluator.h
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/ai/RallyExecutor.h
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/ai/RepairEvaluator.h
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/ai/RepairExecutor.h
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/ai/RetreatEvaluator.h
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/ai/RetreatExecutor.h
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/ai/SalvageScrapEvaluator.h
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/ai/SalvageScrapExecutor.h
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/AiSystem.h
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/CombatSystem.h
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/DynamicBodySystem.h
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/MovementIntentSystem.h
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/RepairSystem.h
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/SalvagerSystem.h
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/ScrapSystem.h
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/ShipSystem.h
|
||||
PARENT_SCOPE
|
||||
@@ -11,10 +28,27 @@ SET(HDRS
|
||||
|
||||
SET(SRCS
|
||||
${SRCS}
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/ai/AdvanceEvaluator.cpp
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/ai/AdvanceExecutor.cpp
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/ai/AttackEvaluator.cpp
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/ai/AttackExecutor.cpp
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/ai/BehaviorTargeting.cpp
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/ai/DeliverScrapEvaluator.cpp
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/ai/DeliverScrapExecutor.cpp
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/ai/RallyEvaluator.cpp
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/ai/RallyExecutor.cpp
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/ai/RepairEvaluator.cpp
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/ai/RepairExecutor.cpp
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/ai/RetreatEvaluator.cpp
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/ai/RetreatExecutor.cpp
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/ai/SalvageScrapEvaluator.cpp
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/ai/SalvageScrapExecutor.cpp
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/AiSystem.cpp
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/CombatSystem.cpp
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/DynamicBodySystem.cpp
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/MovementIntentSystem.cpp
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/RepairSystem.cpp
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/SalvagerSystem.cpp
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/ScrapSystem.cpp
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/ShipSystem.cpp
|
||||
PARENT_SCOPE
|
||||
@@ -23,5 +57,6 @@ SET(SRCS
|
||||
set(LIB_INCLUDE_PATH
|
||||
${LIB_INCLUDE_PATH}
|
||||
${CMAKE_CURRENT_SOURCE_DIR}
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/ai
|
||||
PARENT_SCOPE
|
||||
)
|
||||
|
||||
@@ -7,7 +7,6 @@
|
||||
#include "PositionComponent.h"
|
||||
#include "SensorRangeComponent.h"
|
||||
#include "ShipIdentityComponent.h"
|
||||
#include "ThreatResponseBehaviorComponent.h"
|
||||
#include "tracing.h"
|
||||
#include "WeaponComponent.h"
|
||||
|
||||
@@ -25,14 +24,11 @@ void CombatSystem::tick(Tick currentTick,
|
||||
{
|
||||
TRACE();
|
||||
// All weapons (ships and stations) are child entities linked via ModuleOwnerComponent.
|
||||
// AttackExecutor has already set each weapon's preferred (in-range) target; here we
|
||||
// validate it, fall back to nearest-target acquisition, and fire.
|
||||
admin.forEach<WeaponComponent, ModuleOwnerComponent>(
|
||||
[&](entt::entity /*e*/, WeaponComponent& weapon, const ModuleOwnerComponent& owner)
|
||||
{
|
||||
if (admin.hasAll<ThreatResponseBehaviorComponent>(owner.owner))
|
||||
{
|
||||
weapon.currentTarget =
|
||||
admin.get<ThreatResponseBehaviorComponent>(owner.owner).currentTarget;
|
||||
}
|
||||
const PositionComponent& pos = admin.get<PositionComponent>(owner.owner);
|
||||
const FactionComponent& faction = admin.get<FactionComponent>(owner.owner);
|
||||
resolveWeapon(owner.owner, weapon, pos, faction, currentTick, admin, outWeaponFiredEvents);
|
||||
|
||||
@@ -29,7 +29,7 @@ void MovementIntentSystem::tick(EntityAdmin& admin)
|
||||
[](entt::entity /*e*/, const PositionComponent& pos, const FacingComponent& facing,
|
||||
DynamicBodyComponent& body, const MovementIntentComponent& intent)
|
||||
{
|
||||
if (intent.priority == 0)
|
||||
if (!intent.active)
|
||||
{
|
||||
// No movement intent: brake using available thrust.
|
||||
const float linearBraking = std::min(body.velocity_tpt.length(),
|
||||
|
||||
70
src/lib/ecs/system/RepairSystem.cpp
Normal file
70
src/lib/ecs/system/RepairSystem.cpp
Normal file
@@ -0,0 +1,70 @@
|
||||
#include "RepairSystem.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <optional>
|
||||
#include <vector>
|
||||
|
||||
#include <QVector2D>
|
||||
|
||||
#include "BehaviorTargeting.h"
|
||||
#include "EntityAdmin.h"
|
||||
#include "HealthComponent.h"
|
||||
#include "ModuleOwnerComponent.h"
|
||||
#include "PositionComponent.h"
|
||||
#include "RepairToolComponent.h"
|
||||
#include "tracing.h"
|
||||
|
||||
RepairSystem::RepairSystem(EntityAdmin& admin)
|
||||
: m_admin(admin)
|
||||
{
|
||||
}
|
||||
|
||||
void RepairSystem::tick()
|
||||
{
|
||||
TRACE();
|
||||
const std::vector<RepairableInfo> repairables = buildRepairables(m_admin);
|
||||
|
||||
m_admin.forEach<RepairToolComponent, ModuleOwnerComponent>(
|
||||
[&](entt::entity /*re*/, RepairToolComponent& tool, const ModuleOwnerComponent& owner)
|
||||
{
|
||||
if (!m_admin.hasAll<PositionComponent>(owner.owner)) { return; }
|
||||
const QVector2D ownerPos = m_admin.get<PositionComponent>(owner.owner).value;
|
||||
|
||||
// Honour the executor-set target if it is still valid and in range.
|
||||
if (tool.currentTarget)
|
||||
{
|
||||
const entt::entity t = *tool.currentTarget;
|
||||
if (m_admin.isValid(t) && m_admin.hasAll<HealthComponent, PositionComponent>(t))
|
||||
{
|
||||
HealthComponent& th = m_admin.get<HealthComponent>(t);
|
||||
const float dist =
|
||||
(m_admin.get<PositionComponent>(t).value - ownerPos).length();
|
||||
if (th.hp > 0.0f && th.hp < th.maxHp && dist <= tool.range_tiles)
|
||||
{
|
||||
th.hp = std::min(th.hp + tool.ratePerTick, th.maxHp);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: heal the nearest damaged friendly within tool range.
|
||||
tool.currentTarget = std::nullopt;
|
||||
float bestDist = tool.range_tiles;
|
||||
for (const RepairableInfo& r : repairables)
|
||||
{
|
||||
if (r.isEnemy) { continue; }
|
||||
if (r.hp <= 0.0f || r.hp >= r.maxHp) { continue; }
|
||||
const float dist = (r.position - ownerPos).length();
|
||||
if (dist < bestDist)
|
||||
{
|
||||
bestDist = dist;
|
||||
tool.currentTarget = r.entity;
|
||||
}
|
||||
}
|
||||
|
||||
if (!tool.currentTarget) { return; }
|
||||
|
||||
HealthComponent& targetHealth = m_admin.get<HealthComponent>(*tool.currentTarget);
|
||||
targetHealth.hp = std::min(targetHealth.hp + tool.ratePerTick, targetHealth.maxHp);
|
||||
});
|
||||
}
|
||||
17
src/lib/ecs/system/RepairSystem.h
Normal file
17
src/lib/ecs/system/RepairSystem.h
Normal file
@@ -0,0 +1,17 @@
|
||||
#pragma once
|
||||
|
||||
class EntityAdmin;
|
||||
|
||||
// World-mutation system for repair modules: validates each tool's target (set by
|
||||
// RepairExecutor), falls back to the nearest damaged friendly in range, and
|
||||
// applies healing. Runs every tick, independent of behavior selection.
|
||||
class RepairSystem
|
||||
{
|
||||
public:
|
||||
explicit RepairSystem(EntityAdmin& admin);
|
||||
|
||||
void tick();
|
||||
|
||||
private:
|
||||
EntityAdmin& m_admin;
|
||||
};
|
||||
79
src/lib/ecs/system/SalvagerSystem.cpp
Normal file
79
src/lib/ecs/system/SalvagerSystem.cpp
Normal file
@@ -0,0 +1,79 @@
|
||||
#include "SalvagerSystem.h"
|
||||
|
||||
#include <vector>
|
||||
|
||||
#include <QVector2D>
|
||||
|
||||
#include "Building.h"
|
||||
#include "BuildingSystem.h"
|
||||
#include "DeliverScrapBehavior.h"
|
||||
#include "EntityAdmin.h"
|
||||
#include "ModuleOwnerComponent.h"
|
||||
#include "PositionComponent.h"
|
||||
#include "SalvageCargoComponent.h"
|
||||
#include "ScrapSystem.h"
|
||||
#include "tracing.h"
|
||||
|
||||
SalvagerSystem::SalvagerSystem(EntityAdmin& admin)
|
||||
: m_admin(admin)
|
||||
{
|
||||
}
|
||||
|
||||
void SalvagerSystem::tick(ScrapSystem& scraps, BuildingSystem& buildings)
|
||||
{
|
||||
TRACE();
|
||||
const std::vector<ScrapInfo> allScrap = scraps.allScrapInfo();
|
||||
|
||||
// Tick down per-module collection cooldowns.
|
||||
m_admin.forEach<SalvageCargoComponent>(
|
||||
[](entt::entity /*e*/, SalvageCargoComponent& c)
|
||||
{
|
||||
if (c.cooldownTicksRemaining > 0) { --c.cooldownTicksRemaining; }
|
||||
});
|
||||
|
||||
// Collection: each ready, in-range module collects one scrap.
|
||||
m_admin.forEach<SalvageCargoComponent, ModuleOwnerComponent>(
|
||||
[&](entt::entity /*ce*/, SalvageCargoComponent& c, const ModuleOwnerComponent& o)
|
||||
{
|
||||
if (c.current >= c.capacity || c.cooldownTicksRemaining > 0) { return; }
|
||||
if (!m_admin.hasAll<PositionComponent>(o.owner)) { return; }
|
||||
|
||||
const QVector2D ownerPos = m_admin.get<PositionComponent>(o.owner).value;
|
||||
for (const ScrapInfo& si : allScrap)
|
||||
{
|
||||
if ((si.position - ownerPos).length() > c.collectionRange_tiles) { continue; }
|
||||
if (scraps.consume(si.entity))
|
||||
{
|
||||
++c.current;
|
||||
c.cooldownTicksRemaining = c.collectionIntervalTicks;
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Delivery: a ship at its assigned bay hands over one unit of cargo per tick.
|
||||
m_admin.forEach<DeliverScrapBehavior, PositionComponent>(
|
||||
[&](entt::entity ship, const DeliverScrapBehavior& deliver, const PositionComponent& pos)
|
||||
{
|
||||
if (deliver.deliveryBay == kInvalidBuildingId) { return; }
|
||||
const Building* bay = buildings.findBuilding(deliver.deliveryBay);
|
||||
if (!bay) { return; }
|
||||
|
||||
const QVector2D bayCenter(bay->anchor.x() + bay->footprint.width() / 2.0f,
|
||||
bay->anchor.y() + bay->footprint.height() / 2.0f);
|
||||
if ((pos.value - bayCenter).length() > 1.0f) { return; }
|
||||
|
||||
// Decrement the first non-empty salvage child belonging to this ship.
|
||||
bool delivered = false;
|
||||
m_admin.forEach<SalvageCargoComponent, ModuleOwnerComponent>(
|
||||
[&](entt::entity /*ce*/, SalvageCargoComponent& c, const ModuleOwnerComponent& o)
|
||||
{
|
||||
if (delivered || o.owner != ship || c.current <= 0) { return; }
|
||||
if (buildings.deliverScrapToSalvageBay(deliver.deliveryBay))
|
||||
{
|
||||
--c.current;
|
||||
delivered = true;
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
19
src/lib/ecs/system/SalvagerSystem.h
Normal file
19
src/lib/ecs/system/SalvagerSystem.h
Normal file
@@ -0,0 +1,19 @@
|
||||
#pragma once
|
||||
|
||||
class BuildingSystem;
|
||||
class EntityAdmin;
|
||||
class ScrapSystem;
|
||||
|
||||
// World-mutation system for salvage modules: collects scrap into cargo and
|
||||
// delivers full cargo at a SalvageBay. Runs every tick, independent of which
|
||||
// behavior the AiSystem selected.
|
||||
class SalvagerSystem
|
||||
{
|
||||
public:
|
||||
explicit SalvagerSystem(EntityAdmin& admin);
|
||||
|
||||
void tick(ScrapSystem& scraps, BuildingSystem& buildings);
|
||||
|
||||
private:
|
||||
EntityAdmin& m_admin;
|
||||
};
|
||||
@@ -6,6 +6,10 @@
|
||||
#include <utility>
|
||||
#include <vector>
|
||||
|
||||
#include "AdvanceBehavior.h"
|
||||
#include "AttackBehavior.h"
|
||||
#include "BehaviorScores.h"
|
||||
#include "DeliverScrapBehavior.h"
|
||||
#include "DynamicBodyComponent.h"
|
||||
#include "EntityAdmin.h"
|
||||
#include "FactionComponent.h"
|
||||
@@ -13,14 +17,15 @@
|
||||
#include "ModuleOwnerComponent.h"
|
||||
#include "ModulesConfig.h"
|
||||
#include "MovementIntentComponent.h"
|
||||
#include "RallyBehaviorComponent.h"
|
||||
#include "RepairBehaviorComponent.h"
|
||||
#include "RallyBehavior.h"
|
||||
#include "RepairBehavior.h"
|
||||
#include "RepairToolComponent.h"
|
||||
#include "SalvageBehaviorComponent.h"
|
||||
#include "RetreatBehavior.h"
|
||||
#include "SalvageCargoComponent.h"
|
||||
#include "SalvageScrapBehavior.h"
|
||||
#include "SelectedBehaviorComponent.h"
|
||||
#include "SensorRangeComponent.h"
|
||||
#include "Tick.h"
|
||||
#include "ThreatResponseBehaviorComponent.h"
|
||||
#include "tracing.h"
|
||||
#include "WeaponComponent.h"
|
||||
|
||||
@@ -321,15 +326,30 @@ entt::entity ShipSystem::spawn(const std::string& schematicId, int level,
|
||||
|
||||
// --- Pass 3: attach behavior components based on capability presence -----
|
||||
|
||||
// Baseline: every ship can always fall back to advancing, and needs a slot
|
||||
// for the per-tick behavior selection result.
|
||||
m_admin.addComponent<AdvanceBehavior>(entity, AdvanceBehavior{});
|
||||
m_admin.addComponent<SelectedBehaviorComponent>(entity, SelectedBehaviorComponent{});
|
||||
|
||||
// Player ships retreat to the rally point when threatened or badly damaged
|
||||
// (disabled by the balancing tool to keep arena fights symmetric).
|
||||
if (!isEnemy && m_retreatEnabled)
|
||||
{
|
||||
RetreatBehavior retreat;
|
||||
retreat.retreatHpFraction = BehaviorScores::kLowHpFraction;
|
||||
retreat.retreatPoint = m_rallyPoint;
|
||||
m_admin.addComponent<RetreatBehavior>(entity, retreat);
|
||||
}
|
||||
|
||||
if (!weaponChildren.empty())
|
||||
{
|
||||
m_admin.addComponent<ThreatResponseBehaviorComponent>(
|
||||
entity, ThreatResponseBehaviorComponent{});
|
||||
m_admin.addComponent<AttackBehavior>(entity, AttackBehavior{});
|
||||
|
||||
if (!isEnemy)
|
||||
{
|
||||
m_admin.addComponent<RallyBehaviorComponent>(
|
||||
entity, RallyBehaviorComponent{m_rallyPoint});
|
||||
RallyBehavior rally;
|
||||
rally.rallyPoint = m_rallyPoint;
|
||||
m_admin.addComponent<RallyBehavior>(entity, rally);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -342,11 +362,14 @@ entt::entity ShipSystem::spawn(const std::string& schematicId, int level,
|
||||
if (r > maxCollRange) { maxCollRange = r; }
|
||||
}
|
||||
|
||||
SalvageBehaviorComponent sb;
|
||||
sb.scrapTarget = std::nullopt;
|
||||
sb.deliveryBay = kInvalidBuildingId;
|
||||
sb.maxCollectionRange_tiles = maxCollRange;
|
||||
m_admin.addComponent<SalvageBehaviorComponent>(entity, sb);
|
||||
SalvageScrapBehavior salvage;
|
||||
salvage.scrapTarget = std::nullopt;
|
||||
salvage.maxCollectionRange_tiles = maxCollRange;
|
||||
m_admin.addComponent<SalvageScrapBehavior>(entity, salvage);
|
||||
|
||||
DeliverScrapBehavior deliver;
|
||||
deliver.deliveryBay = kInvalidBuildingId;
|
||||
m_admin.addComponent<DeliverScrapBehavior>(entity, deliver);
|
||||
}
|
||||
|
||||
if (!repairChildren.empty())
|
||||
@@ -358,10 +381,10 @@ entt::entity ShipSystem::spawn(const std::string& schematicId, int level,
|
||||
if (r > maxRepairRange) { maxRepairRange = r; }
|
||||
}
|
||||
|
||||
RepairBehaviorComponent rb;
|
||||
rb.currentTarget = std::nullopt;
|
||||
rb.maxRepairRange_tiles = maxRepairRange;
|
||||
m_admin.addComponent<RepairBehaviorComponent>(entity, rb);
|
||||
RepairBehavior repair;
|
||||
repair.currentTarget = std::nullopt;
|
||||
repair.maxRepairRange_tiles = maxRepairRange;
|
||||
m_admin.addComponent<RepairBehavior>(entity, repair);
|
||||
}
|
||||
|
||||
return entity;
|
||||
@@ -385,7 +408,7 @@ void ShipSystem::clearMovementIntents()
|
||||
m_admin.forEach<MovementIntentComponent>(
|
||||
[](entt::entity /*e*/, MovementIntentComponent& i)
|
||||
{
|
||||
i = MovementIntentComponent{0, QVector2D(0.0f, 0.0f)};
|
||||
i = MovementIntentComponent{false, QVector2D(0.0f, 0.0f)};
|
||||
});
|
||||
}
|
||||
|
||||
@@ -394,12 +417,17 @@ void ShipSystem::setRallyPoint(QVector2D point)
|
||||
m_rallyPoint = point;
|
||||
}
|
||||
|
||||
void ShipSystem::setRetreatEnabled(bool enabled)
|
||||
{
|
||||
m_retreatEnabled = enabled;
|
||||
}
|
||||
|
||||
void ShipSystem::triggerRallyDeparture()
|
||||
{
|
||||
TRACE();
|
||||
std::vector<entt::entity> toRemove;
|
||||
m_admin.forEach<RallyBehaviorComponent, FactionComponent>(
|
||||
[&toRemove](entt::entity e, const RallyBehaviorComponent& /*rb*/,
|
||||
m_admin.forEach<RallyBehavior, FactionComponent>(
|
||||
[&toRemove](entt::entity e, const RallyBehavior& /*rb*/,
|
||||
const FactionComponent& f)
|
||||
{
|
||||
if (!f.isEnemy)
|
||||
@@ -409,6 +437,6 @@ void ShipSystem::triggerRallyDeparture()
|
||||
});
|
||||
for (entt::entity e : toRemove)
|
||||
{
|
||||
m_admin.removeComponent<RallyBehaviorComponent>(e);
|
||||
m_admin.removeComponent<RallyBehavior>(e);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,7 +24,7 @@ public:
|
||||
const std::map<std::string, int>& moduleLevelOverrides = {});
|
||||
void despawn(entt::entity entity);
|
||||
|
||||
// Reset all movement intents to priority 0 before behavior systems run.
|
||||
// Reset all movement intents to inactive before behavior systems run.
|
||||
void clearMovementIntents();
|
||||
|
||||
// Set the rally point that newly spawned player combat ships will loiter at.
|
||||
@@ -33,6 +33,11 @@ public:
|
||||
// Release all gathered player combat ships to advance toward the enemy.
|
||||
void triggerRallyDeparture();
|
||||
|
||||
// Controls whether newly spawned player ships receive a RetreatBehavior. The
|
||||
// balancing tool disables this so arena fights stay symmetric and aggressive
|
||||
// (REQ-BAL-SIM-AI); the main game keeps it enabled (REQ-SHP-RETREAT).
|
||||
void setRetreatEnabled(bool enabled);
|
||||
|
||||
private:
|
||||
const ShipDef* findShipDef(const std::string& schematicId) const;
|
||||
const ModuleDef* findModuleDef(const std::string& id) const;
|
||||
@@ -40,4 +45,5 @@ private:
|
||||
const GameConfig& m_config;
|
||||
EntityAdmin& m_admin;
|
||||
QVector2D m_rallyPoint;
|
||||
bool m_retreatEnabled = true;
|
||||
};
|
||||
|
||||
16
src/lib/ecs/system/ai/AdvanceEvaluator.cpp
Normal file
16
src/lib/ecs/system/ai/AdvanceEvaluator.cpp
Normal file
@@ -0,0 +1,16 @@
|
||||
#include "AdvanceEvaluator.h"
|
||||
|
||||
#include "AdvanceBehavior.h"
|
||||
#include "BehaviorScores.h"
|
||||
#include "EntityAdmin.h"
|
||||
#include "tracing.h"
|
||||
|
||||
void AdvanceEvaluator::evaluate(EntityAdmin& admin)
|
||||
{
|
||||
TRACE();
|
||||
admin.forEach<AdvanceBehavior>(
|
||||
[](entt::entity /*e*/, AdvanceBehavior& advance)
|
||||
{
|
||||
advance.score = BehaviorScores::kAdvance;
|
||||
});
|
||||
}
|
||||
11
src/lib/ecs/system/ai/AdvanceEvaluator.h
Normal file
11
src/lib/ecs/system/ai/AdvanceEvaluator.h
Normal file
@@ -0,0 +1,11 @@
|
||||
#pragma once
|
||||
|
||||
class EntityAdmin;
|
||||
|
||||
// Baseline fallback: gives every ship a constant low score so there is always a
|
||||
// winning behavior. The actual movement direction is decided by AdvanceExecutor.
|
||||
class AdvanceEvaluator
|
||||
{
|
||||
public:
|
||||
void evaluate(EntityAdmin& admin);
|
||||
};
|
||||
30
src/lib/ecs/system/ai/AdvanceExecutor.cpp
Normal file
30
src/lib/ecs/system/ai/AdvanceExecutor.cpp
Normal file
@@ -0,0 +1,30 @@
|
||||
#include "AdvanceExecutor.h"
|
||||
|
||||
#include <QVector2D>
|
||||
|
||||
#include "AdvanceBehavior.h"
|
||||
#include "BehaviorKind.h"
|
||||
#include "EntityAdmin.h"
|
||||
#include "FactionComponent.h"
|
||||
#include "MovementIntentComponent.h"
|
||||
#include "PositionComponent.h"
|
||||
#include "SelectedBehaviorComponent.h"
|
||||
#include "tracing.h"
|
||||
|
||||
void AdvanceExecutor::execute(EntityAdmin& admin)
|
||||
{
|
||||
TRACE();
|
||||
admin.forEach<AdvanceBehavior, SelectedBehaviorComponent, PositionComponent,
|
||||
FactionComponent, MovementIntentComponent>(
|
||||
[](entt::entity /*e*/, const AdvanceBehavior& /*advance*/,
|
||||
const SelectedBehaviorComponent& selected, const PositionComponent& pos,
|
||||
const FactionComponent& faction, MovementIntentComponent& intent)
|
||||
{
|
||||
if (selected.winner != BehaviorKind::Advance) { return; }
|
||||
|
||||
const QVector2D target = faction.isEnemy
|
||||
? QVector2D(-10000.0f, pos.value.y())
|
||||
: QVector2D(pos.value.x() + 1000.0f, pos.value.y());
|
||||
intent = MovementIntentComponent{true, target};
|
||||
});
|
||||
}
|
||||
11
src/lib/ecs/system/ai/AdvanceExecutor.h
Normal file
11
src/lib/ecs/system/ai/AdvanceExecutor.h
Normal file
@@ -0,0 +1,11 @@
|
||||
#pragma once
|
||||
|
||||
class EntityAdmin;
|
||||
|
||||
// Moves a ship toward the opposing side when Advance is the winning behavior:
|
||||
// player ships advance toward +x (the enemy), enemy ships toward -x (the base).
|
||||
class AdvanceExecutor
|
||||
{
|
||||
public:
|
||||
void execute(EntityAdmin& admin);
|
||||
};
|
||||
71
src/lib/ecs/system/ai/AttackEvaluator.cpp
Normal file
71
src/lib/ecs/system/ai/AttackEvaluator.cpp
Normal file
@@ -0,0 +1,71 @@
|
||||
#include "AttackEvaluator.h"
|
||||
|
||||
#include <vector>
|
||||
|
||||
#include <QVector2D>
|
||||
|
||||
#include "AttackBehavior.h"
|
||||
#include "BehaviorScores.h"
|
||||
#include "BehaviorTargeting.h"
|
||||
#include "EntityAdmin.h"
|
||||
#include "FactionComponent.h"
|
||||
#include "HealthComponent.h"
|
||||
#include "PositionComponent.h"
|
||||
#include "SensorRangeComponent.h"
|
||||
#include "tracing.h"
|
||||
|
||||
void AttackEvaluator::evaluate(EntityAdmin& admin)
|
||||
{
|
||||
TRACE();
|
||||
const std::vector<CombatantInfo> combatants = buildCombatants(admin);
|
||||
|
||||
admin.forEach<AttackBehavior, PositionComponent, FactionComponent,
|
||||
SensorRangeComponent, HealthComponent>(
|
||||
[&](entt::entity e, AttackBehavior& attack, const PositionComponent& pos,
|
||||
const FactionComponent& faction, const SensorRangeComponent& sensor,
|
||||
const HealthComponent& health)
|
||||
{
|
||||
const float range = sensor.value_tiles;
|
||||
|
||||
// Validate current target: still valid, still in range.
|
||||
bool targetValid = false;
|
||||
if (attack.currentTarget)
|
||||
{
|
||||
const entt::entity t = *attack.currentTarget;
|
||||
if (admin.isValid(t) && admin.hasAll<PositionComponent>(t))
|
||||
{
|
||||
const float dist =
|
||||
(admin.get<PositionComponent>(t).value - pos.value).length();
|
||||
if (dist <= range) { targetValid = true; }
|
||||
}
|
||||
}
|
||||
|
||||
// Acquire nearest valid target if needed.
|
||||
if (!targetValid)
|
||||
{
|
||||
attack.currentTarget = std::nullopt;
|
||||
float bestDist = range;
|
||||
for (const CombatantInfo& c : combatants)
|
||||
{
|
||||
if (c.entity == e) { continue; }
|
||||
const bool isValidTarget =
|
||||
faction.isEnemy ? !c.isEnemy : c.isEnemy;
|
||||
if (!isValidTarget) { continue; }
|
||||
|
||||
const float dist = (c.position - pos.value).length();
|
||||
if (dist < bestDist)
|
||||
{
|
||||
bestDist = dist;
|
||||
attack.currentTarget = c.entity;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const bool healthy =
|
||||
(health.maxHp > 0.0f)
|
||||
&& (health.hp / health.maxHp >= BehaviorScores::kLowHpFraction);
|
||||
attack.score = (healthy && attack.currentTarget)
|
||||
? BehaviorScores::kAttack
|
||||
: BehaviorScores::kInactive;
|
||||
});
|
||||
}
|
||||
11
src/lib/ecs/system/ai/AttackEvaluator.h
Normal file
11
src/lib/ecs/system/ai/AttackEvaluator.h
Normal file
@@ -0,0 +1,11 @@
|
||||
#pragma once
|
||||
|
||||
class EntityAdmin;
|
||||
|
||||
// Acquires/validates a combat target for ships with weapons. Scores high only
|
||||
// when the ship's health is not low and a valid target is within sensor range.
|
||||
class AttackEvaluator
|
||||
{
|
||||
public:
|
||||
void evaluate(EntityAdmin& admin);
|
||||
};
|
||||
61
src/lib/ecs/system/ai/AttackExecutor.cpp
Normal file
61
src/lib/ecs/system/ai/AttackExecutor.cpp
Normal file
@@ -0,0 +1,61 @@
|
||||
#include "AttackExecutor.h"
|
||||
|
||||
#include "AttackBehavior.h"
|
||||
#include "BehaviorKind.h"
|
||||
#include "EntityAdmin.h"
|
||||
#include "ModuleOwnerComponent.h"
|
||||
#include "MovementIntentComponent.h"
|
||||
#include "PositionComponent.h"
|
||||
#include "SelectedBehaviorComponent.h"
|
||||
#include "tracing.h"
|
||||
#include "WeaponComponent.h"
|
||||
|
||||
void AttackExecutor::execute(EntityAdmin& admin)
|
||||
{
|
||||
TRACE();
|
||||
|
||||
// Ships: move toward the behavior target.
|
||||
admin.forEach<AttackBehavior, SelectedBehaviorComponent, PositionComponent,
|
||||
MovementIntentComponent>(
|
||||
[&](entt::entity /*e*/, const AttackBehavior& attack,
|
||||
const SelectedBehaviorComponent& selected, const PositionComponent& pos,
|
||||
MovementIntentComponent& intent)
|
||||
{
|
||||
if (selected.winner != BehaviorKind::Attack) { return; }
|
||||
if (!attack.currentTarget) { return; }
|
||||
|
||||
const entt::entity t = *attack.currentTarget;
|
||||
QVector2D dest = pos.value;
|
||||
if (admin.isValid(t) && admin.hasAll<PositionComponent>(t))
|
||||
{
|
||||
dest = admin.get<PositionComponent>(t).value;
|
||||
}
|
||||
intent = MovementIntentComponent{true, dest};
|
||||
});
|
||||
|
||||
// Weapons: assign the behavior target only if it is within this weapon's range.
|
||||
admin.forEach<WeaponComponent, ModuleOwnerComponent>(
|
||||
[&](entt::entity /*we*/, WeaponComponent& weapon, const ModuleOwnerComponent& owner)
|
||||
{
|
||||
if (!admin.hasAll<AttackBehavior, SelectedBehaviorComponent>(owner.owner))
|
||||
{
|
||||
return;
|
||||
}
|
||||
const SelectedBehaviorComponent& selected =
|
||||
admin.get<SelectedBehaviorComponent>(owner.owner);
|
||||
if (selected.winner != BehaviorKind::Attack) { return; }
|
||||
|
||||
const AttackBehavior& attack = admin.get<AttackBehavior>(owner.owner);
|
||||
if (!attack.currentTarget) { return; }
|
||||
|
||||
const entt::entity t = *attack.currentTarget;
|
||||
if (!admin.isValid(t) || !admin.hasAll<PositionComponent>(t)) { return; }
|
||||
|
||||
const QVector2D ownerPos = admin.get<PositionComponent>(owner.owner).value;
|
||||
const float dist = (admin.get<PositionComponent>(t).value - ownerPos).length();
|
||||
if (dist <= weapon.range_tiles)
|
||||
{
|
||||
weapon.currentTarget = t;
|
||||
}
|
||||
});
|
||||
}
|
||||
12
src/lib/ecs/system/ai/AttackExecutor.h
Normal file
12
src/lib/ecs/system/ai/AttackExecutor.h
Normal file
@@ -0,0 +1,12 @@
|
||||
#pragma once
|
||||
|
||||
class EntityAdmin;
|
||||
|
||||
// When Attack wins, moves the ship toward its target and assigns that target to
|
||||
// each weapon that has it in range. Weapons whose range excludes the target are
|
||||
// left untouched so CombatSystem can keep/acquire a closer target (no thrash).
|
||||
class AttackExecutor
|
||||
{
|
||||
public:
|
||||
void execute(EntityAdmin& admin);
|
||||
};
|
||||
81
src/lib/ecs/system/ai/BehaviorTargeting.cpp
Normal file
81
src/lib/ecs/system/ai/BehaviorTargeting.cpp
Normal file
@@ -0,0 +1,81 @@
|
||||
#include "BehaviorTargeting.h"
|
||||
|
||||
#include "EntityAdmin.h"
|
||||
#include "FactionComponent.h"
|
||||
#include "HealthComponent.h"
|
||||
#include "HqProxyComponent.h"
|
||||
#include "ModuleOwnerComponent.h"
|
||||
#include "PositionComponent.h"
|
||||
#include "SalvageCargoComponent.h"
|
||||
#include "ShipIdentityComponent.h"
|
||||
#include "StationBodyComponent.h"
|
||||
|
||||
std::vector<RepairableInfo> buildRepairables(EntityAdmin& admin)
|
||||
{
|
||||
std::vector<RepairableInfo> repairables;
|
||||
|
||||
admin.forEach<ShipIdentityComponent, PositionComponent, FactionComponent, HealthComponent>(
|
||||
[&repairables](entt::entity e, const ShipIdentityComponent& /*si*/,
|
||||
const PositionComponent& pos, const FactionComponent& f,
|
||||
const HealthComponent& h)
|
||||
{
|
||||
repairables.push_back({e, pos.value, f.isEnemy, true, h.hp, h.maxHp});
|
||||
});
|
||||
|
||||
admin.forEach<StationBodyComponent, PositionComponent, FactionComponent, HealthComponent>(
|
||||
[&repairables](entt::entity e, const StationBodyComponent& /*sb*/,
|
||||
const PositionComponent& pos, const FactionComponent& f,
|
||||
const HealthComponent& h)
|
||||
{
|
||||
repairables.push_back({e, pos.value, f.isEnemy, false, h.hp, h.maxHp});
|
||||
});
|
||||
|
||||
return repairables;
|
||||
}
|
||||
|
||||
std::vector<CombatantInfo> buildCombatants(EntityAdmin& admin)
|
||||
{
|
||||
std::vector<CombatantInfo> combatants;
|
||||
|
||||
admin.forEach<PositionComponent, FactionComponent, ShipIdentityComponent>(
|
||||
[&combatants](entt::entity e, const PositionComponent& pos,
|
||||
const FactionComponent& f, const ShipIdentityComponent& /*si*/)
|
||||
{
|
||||
combatants.push_back({e, pos.value, f.isEnemy, false});
|
||||
});
|
||||
|
||||
admin.forEach<PositionComponent, FactionComponent, StationBodyComponent>(
|
||||
[&combatants](entt::entity e, const PositionComponent& pos,
|
||||
const FactionComponent& f, const StationBodyComponent& /*sb*/)
|
||||
{
|
||||
combatants.push_back({e, pos.value, f.isEnemy, true});
|
||||
});
|
||||
|
||||
admin.forEach<PositionComponent, FactionComponent, HqProxyComponent>(
|
||||
[&combatants](entt::entity e, const PositionComponent& pos,
|
||||
const FactionComponent& f, const HqProxyComponent& /*hq*/)
|
||||
{
|
||||
combatants.push_back({e, pos.value, f.isEnemy, true});
|
||||
});
|
||||
|
||||
return combatants;
|
||||
}
|
||||
|
||||
std::unordered_map<entt::entity, CargoState> buildCargoByShip(EntityAdmin& admin)
|
||||
{
|
||||
std::unordered_map<entt::entity, CargoState> cargoByShip;
|
||||
admin.forEach<SalvageCargoComponent, ModuleOwnerComponent>(
|
||||
[&cargoByShip](entt::entity /*ce*/, const SalvageCargoComponent& c,
|
||||
const ModuleOwnerComponent& o)
|
||||
{
|
||||
CargoState& agg = cargoByShip[o.owner];
|
||||
agg.current += c.current;
|
||||
agg.capacity += c.capacity;
|
||||
});
|
||||
return cargoByShip;
|
||||
}
|
||||
|
||||
bool isCargoFull(const CargoState& cargo)
|
||||
{
|
||||
return cargo.capacity > 0 && cargo.current >= cargo.capacity;
|
||||
}
|
||||
49
src/lib/ecs/system/ai/BehaviorTargeting.h
Normal file
49
src/lib/ecs/system/ai/BehaviorTargeting.h
Normal file
@@ -0,0 +1,49 @@
|
||||
#pragma once
|
||||
|
||||
#include <unordered_map>
|
||||
#include <vector>
|
||||
|
||||
#include <QVector2D>
|
||||
|
||||
#include "entt/entity/entity.hpp"
|
||||
|
||||
class EntityAdmin;
|
||||
|
||||
// Shared, per-call target snapshots used by behavior evaluators and the repair
|
||||
// system. Each caller builds its own snapshot (no cross-system caching).
|
||||
|
||||
struct RepairableInfo
|
||||
{
|
||||
entt::entity entity;
|
||||
QVector2D position;
|
||||
bool isEnemy;
|
||||
bool isShip;
|
||||
float hp;
|
||||
float maxHp;
|
||||
};
|
||||
|
||||
struct CombatantInfo
|
||||
{
|
||||
entt::entity entity;
|
||||
QVector2D position;
|
||||
bool isEnemy;
|
||||
bool isStation;
|
||||
};
|
||||
|
||||
struct CargoState
|
||||
{
|
||||
int current = 0;
|
||||
int capacity = 0;
|
||||
};
|
||||
|
||||
// All ships and stations with health — candidates for repair targeting.
|
||||
std::vector<RepairableInfo> buildRepairables(EntityAdmin& admin);
|
||||
|
||||
// All ships, stations, and the HQ proxy — candidates for attack targeting.
|
||||
std::vector<CombatantInfo> buildCombatants(EntityAdmin& admin);
|
||||
|
||||
// Aggregated salvage cargo per owning ship, summed across its salvage modules.
|
||||
std::unordered_map<entt::entity, CargoState> buildCargoByShip(EntityAdmin& admin);
|
||||
|
||||
// True when the ship's aggregated cargo is at capacity (and it has any capacity).
|
||||
bool isCargoFull(const CargoState& cargo);
|
||||
43
src/lib/ecs/system/ai/DeliverScrapEvaluator.cpp
Normal file
43
src/lib/ecs/system/ai/DeliverScrapEvaluator.cpp
Normal file
@@ -0,0 +1,43 @@
|
||||
#include "DeliverScrapEvaluator.h"
|
||||
|
||||
#include <unordered_map>
|
||||
|
||||
#include "BehaviorScores.h"
|
||||
#include "BehaviorTargeting.h"
|
||||
#include "Building.h"
|
||||
#include "BuildingSystem.h"
|
||||
#include "BuildingType.h"
|
||||
#include "DeliverScrapBehavior.h"
|
||||
#include "EntityAdmin.h"
|
||||
#include "PositionComponent.h"
|
||||
#include "tracing.h"
|
||||
|
||||
void DeliverScrapEvaluator::evaluate(EntityAdmin& admin, const BuildingSystem& buildings)
|
||||
{
|
||||
TRACE();
|
||||
const std::unordered_map<entt::entity, CargoState> cargoByShip = buildCargoByShip(admin);
|
||||
|
||||
admin.forEach<DeliverScrapBehavior, PositionComponent>(
|
||||
[&](entt::entity e, DeliverScrapBehavior& deliver, const PositionComponent& pos)
|
||||
{
|
||||
const std::unordered_map<entt::entity, CargoState>::const_iterator it =
|
||||
cargoByShip.find(e);
|
||||
const bool cargoFull = (it != cargoByShip.end()) && isCargoFull(it->second);
|
||||
|
||||
if (!cargoFull)
|
||||
{
|
||||
deliver.score = BehaviorScores::kInactive;
|
||||
return;
|
||||
}
|
||||
|
||||
// Assign nearest SalvageBay if not yet assigned.
|
||||
if (deliver.deliveryBay == kInvalidBuildingId)
|
||||
{
|
||||
const Building* bay =
|
||||
buildings.findNearestBuilding(pos.value, BuildingType::SalvageBay);
|
||||
if (bay) { deliver.deliveryBay = bay->id; }
|
||||
}
|
||||
|
||||
deliver.score = BehaviorScores::kDeliver;
|
||||
});
|
||||
}
|
||||
12
src/lib/ecs/system/ai/DeliverScrapEvaluator.h
Normal file
12
src/lib/ecs/system/ai/DeliverScrapEvaluator.h
Normal file
@@ -0,0 +1,12 @@
|
||||
#pragma once
|
||||
|
||||
class EntityAdmin;
|
||||
class BuildingSystem;
|
||||
|
||||
// Scores high only when the ship's cargo is full, and assigns the nearest
|
||||
// SalvageBay as the delivery destination.
|
||||
class DeliverScrapEvaluator
|
||||
{
|
||||
public:
|
||||
void evaluate(EntityAdmin& admin, const BuildingSystem& buildings);
|
||||
};
|
||||
38
src/lib/ecs/system/ai/DeliverScrapExecutor.cpp
Normal file
38
src/lib/ecs/system/ai/DeliverScrapExecutor.cpp
Normal file
@@ -0,0 +1,38 @@
|
||||
#include "DeliverScrapExecutor.h"
|
||||
|
||||
#include <QVector2D>
|
||||
|
||||
#include "BehaviorKind.h"
|
||||
#include "Building.h"
|
||||
#include "BuildingSystem.h"
|
||||
#include "DeliverScrapBehavior.h"
|
||||
#include "EntityAdmin.h"
|
||||
#include "MovementIntentComponent.h"
|
||||
#include "PositionComponent.h"
|
||||
#include "SelectedBehaviorComponent.h"
|
||||
#include "tracing.h"
|
||||
|
||||
void DeliverScrapExecutor::execute(EntityAdmin& admin, const BuildingSystem& buildings)
|
||||
{
|
||||
TRACE();
|
||||
admin.forEach<DeliverScrapBehavior, SelectedBehaviorComponent, PositionComponent,
|
||||
MovementIntentComponent>(
|
||||
[&](entt::entity /*e*/, const DeliverScrapBehavior& deliver,
|
||||
const SelectedBehaviorComponent& selected, const PositionComponent& pos,
|
||||
MovementIntentComponent& intent)
|
||||
{
|
||||
if (selected.winner != BehaviorKind::DeliverScrap) { return; }
|
||||
|
||||
QVector2D dest = pos.value;
|
||||
if (deliver.deliveryBay != kInvalidBuildingId)
|
||||
{
|
||||
const Building* bay = buildings.findBuilding(deliver.deliveryBay);
|
||||
if (bay)
|
||||
{
|
||||
dest = QVector2D(bay->anchor.x() + bay->footprint.width() / 2.0f,
|
||||
bay->anchor.y() + bay->footprint.height() / 2.0f);
|
||||
}
|
||||
}
|
||||
intent = MovementIntentComponent{true, dest};
|
||||
});
|
||||
}
|
||||
12
src/lib/ecs/system/ai/DeliverScrapExecutor.h
Normal file
12
src/lib/ecs/system/ai/DeliverScrapExecutor.h
Normal file
@@ -0,0 +1,12 @@
|
||||
#pragma once
|
||||
|
||||
class EntityAdmin;
|
||||
class BuildingSystem;
|
||||
|
||||
// Moves a ship toward its delivery bay when DeliverScrap is the winning
|
||||
// behavior. Never decrements cargo — SalvagerSystem performs the delivery.
|
||||
class DeliverScrapExecutor
|
||||
{
|
||||
public:
|
||||
void execute(EntityAdmin& admin, const BuildingSystem& buildings);
|
||||
};
|
||||
16
src/lib/ecs/system/ai/RallyEvaluator.cpp
Normal file
16
src/lib/ecs/system/ai/RallyEvaluator.cpp
Normal file
@@ -0,0 +1,16 @@
|
||||
#include "RallyEvaluator.h"
|
||||
|
||||
#include "BehaviorScores.h"
|
||||
#include "EntityAdmin.h"
|
||||
#include "RallyBehavior.h"
|
||||
#include "tracing.h"
|
||||
|
||||
void RallyEvaluator::evaluate(EntityAdmin& admin)
|
||||
{
|
||||
TRACE();
|
||||
admin.forEach<RallyBehavior>(
|
||||
[](entt::entity /*e*/, RallyBehavior& rally)
|
||||
{
|
||||
rally.score = BehaviorScores::kRally;
|
||||
});
|
||||
}
|
||||
12
src/lib/ecs/system/ai/RallyEvaluator.h
Normal file
12
src/lib/ecs/system/ai/RallyEvaluator.h
Normal file
@@ -0,0 +1,12 @@
|
||||
#pragma once
|
||||
|
||||
class EntityAdmin;
|
||||
|
||||
// Scores the rally behavior so player combat ships gather at the rally point
|
||||
// until an enemy appears (Attack outscores it) or the departure timer removes
|
||||
// the RallyBehavior component.
|
||||
class RallyEvaluator
|
||||
{
|
||||
public:
|
||||
void evaluate(EntityAdmin& admin);
|
||||
};
|
||||
20
src/lib/ecs/system/ai/RallyExecutor.cpp
Normal file
20
src/lib/ecs/system/ai/RallyExecutor.cpp
Normal file
@@ -0,0 +1,20 @@
|
||||
#include "RallyExecutor.h"
|
||||
|
||||
#include "BehaviorKind.h"
|
||||
#include "EntityAdmin.h"
|
||||
#include "MovementIntentComponent.h"
|
||||
#include "RallyBehavior.h"
|
||||
#include "SelectedBehaviorComponent.h"
|
||||
#include "tracing.h"
|
||||
|
||||
void RallyExecutor::execute(EntityAdmin& admin)
|
||||
{
|
||||
TRACE();
|
||||
admin.forEach<RallyBehavior, SelectedBehaviorComponent, MovementIntentComponent>(
|
||||
[](entt::entity /*e*/, const RallyBehavior& rally,
|
||||
const SelectedBehaviorComponent& selected, MovementIntentComponent& intent)
|
||||
{
|
||||
if (selected.winner != BehaviorKind::Rally) { return; }
|
||||
intent = MovementIntentComponent{true, rally.rallyPoint};
|
||||
});
|
||||
}
|
||||
10
src/lib/ecs/system/ai/RallyExecutor.h
Normal file
10
src/lib/ecs/system/ai/RallyExecutor.h
Normal file
@@ -0,0 +1,10 @@
|
||||
#pragma once
|
||||
|
||||
class EntityAdmin;
|
||||
|
||||
// Moves a ship to its rally point when Rally is the winning behavior.
|
||||
class RallyExecutor
|
||||
{
|
||||
public:
|
||||
void execute(EntityAdmin& admin);
|
||||
};
|
||||
58
src/lib/ecs/system/ai/RepairEvaluator.cpp
Normal file
58
src/lib/ecs/system/ai/RepairEvaluator.cpp
Normal file
@@ -0,0 +1,58 @@
|
||||
#include "RepairEvaluator.h"
|
||||
|
||||
#include <vector>
|
||||
|
||||
#include "BehaviorScores.h"
|
||||
#include "BehaviorTargeting.h"
|
||||
#include "EntityAdmin.h"
|
||||
#include "HealthComponent.h"
|
||||
#include "PositionComponent.h"
|
||||
#include "RepairBehavior.h"
|
||||
#include "SensorRangeComponent.h"
|
||||
#include "tracing.h"
|
||||
|
||||
void RepairEvaluator::evaluate(EntityAdmin& admin)
|
||||
{
|
||||
TRACE();
|
||||
const std::vector<RepairableInfo> repairables = buildRepairables(admin);
|
||||
|
||||
admin.forEach<RepairBehavior, PositionComponent, SensorRangeComponent>(
|
||||
[&](entt::entity e, RepairBehavior& repair, const PositionComponent& pos,
|
||||
const SensorRangeComponent& sensor)
|
||||
{
|
||||
// Validate current target: alive and still damaged.
|
||||
bool targetValid = false;
|
||||
if (repair.currentTarget)
|
||||
{
|
||||
const entt::entity t = *repair.currentTarget;
|
||||
if (admin.isValid(t) && admin.hasAll<HealthComponent>(t))
|
||||
{
|
||||
const HealthComponent& th = admin.get<HealthComponent>(t);
|
||||
if (th.hp > 0.0f && th.hp < th.maxHp) { targetValid = true; }
|
||||
}
|
||||
}
|
||||
|
||||
// Acquire nearest damaged friendly within sensor range.
|
||||
if (!targetValid)
|
||||
{
|
||||
repair.currentTarget = std::nullopt;
|
||||
float bestDist = sensor.value_tiles;
|
||||
for (const RepairableInfo& r : repairables)
|
||||
{
|
||||
if (r.entity == e) { continue; }
|
||||
if (r.isEnemy) { continue; }
|
||||
if (r.hp <= 0.0f || r.hp >= r.maxHp) { continue; }
|
||||
const float dist = (r.position - pos.value).length();
|
||||
if (dist < bestDist)
|
||||
{
|
||||
bestDist = dist;
|
||||
repair.currentTarget = r.entity;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
repair.score = repair.currentTarget
|
||||
? BehaviorScores::kRepair
|
||||
: BehaviorScores::kInactive;
|
||||
});
|
||||
}
|
||||
11
src/lib/ecs/system/ai/RepairEvaluator.h
Normal file
11
src/lib/ecs/system/ai/RepairEvaluator.h
Normal file
@@ -0,0 +1,11 @@
|
||||
#pragma once
|
||||
|
||||
class EntityAdmin;
|
||||
|
||||
// Picks the nearest damaged friendly within sensor range as the repair target.
|
||||
// Scores high when such a target exists.
|
||||
class RepairEvaluator
|
||||
{
|
||||
public:
|
||||
void evaluate(EntityAdmin& admin);
|
||||
};
|
||||
61
src/lib/ecs/system/ai/RepairExecutor.cpp
Normal file
61
src/lib/ecs/system/ai/RepairExecutor.cpp
Normal file
@@ -0,0 +1,61 @@
|
||||
#include "RepairExecutor.h"
|
||||
|
||||
#include "BehaviorKind.h"
|
||||
#include "EntityAdmin.h"
|
||||
#include "ModuleOwnerComponent.h"
|
||||
#include "MovementIntentComponent.h"
|
||||
#include "PositionComponent.h"
|
||||
#include "RepairBehavior.h"
|
||||
#include "RepairToolComponent.h"
|
||||
#include "SelectedBehaviorComponent.h"
|
||||
#include "tracing.h"
|
||||
|
||||
void RepairExecutor::execute(EntityAdmin& admin)
|
||||
{
|
||||
TRACE();
|
||||
|
||||
// Ships: move toward the repair target.
|
||||
admin.forEach<RepairBehavior, SelectedBehaviorComponent, PositionComponent,
|
||||
MovementIntentComponent>(
|
||||
[&](entt::entity /*e*/, const RepairBehavior& repair,
|
||||
const SelectedBehaviorComponent& selected, const PositionComponent& pos,
|
||||
MovementIntentComponent& intent)
|
||||
{
|
||||
if (selected.winner != BehaviorKind::Repair) { return; }
|
||||
if (!repair.currentTarget) { return; }
|
||||
|
||||
const entt::entity t = *repair.currentTarget;
|
||||
QVector2D dest = pos.value;
|
||||
if (admin.isValid(t) && admin.hasAll<PositionComponent>(t))
|
||||
{
|
||||
dest = admin.get<PositionComponent>(t).value;
|
||||
}
|
||||
intent = MovementIntentComponent{true, dest};
|
||||
});
|
||||
|
||||
// Repair tools: prefer the behavior target if it is within tool range.
|
||||
admin.forEach<RepairToolComponent, ModuleOwnerComponent>(
|
||||
[&](entt::entity /*re*/, RepairToolComponent& tool, const ModuleOwnerComponent& owner)
|
||||
{
|
||||
if (!admin.hasAll<RepairBehavior, SelectedBehaviorComponent>(owner.owner))
|
||||
{
|
||||
return;
|
||||
}
|
||||
const SelectedBehaviorComponent& selected =
|
||||
admin.get<SelectedBehaviorComponent>(owner.owner);
|
||||
if (selected.winner != BehaviorKind::Repair) { return; }
|
||||
|
||||
const RepairBehavior& repair = admin.get<RepairBehavior>(owner.owner);
|
||||
if (!repair.currentTarget) { return; }
|
||||
|
||||
const entt::entity t = *repair.currentTarget;
|
||||
if (!admin.isValid(t) || !admin.hasAll<PositionComponent>(t)) { return; }
|
||||
|
||||
const QVector2D ownerPos = admin.get<PositionComponent>(owner.owner).value;
|
||||
const float dist = (admin.get<PositionComponent>(t).value - ownerPos).length();
|
||||
if (dist <= tool.range_tiles)
|
||||
{
|
||||
tool.currentTarget = t;
|
||||
}
|
||||
});
|
||||
}
|
||||
12
src/lib/ecs/system/ai/RepairExecutor.h
Normal file
12
src/lib/ecs/system/ai/RepairExecutor.h
Normal file
@@ -0,0 +1,12 @@
|
||||
#pragma once
|
||||
|
||||
class EntityAdmin;
|
||||
|
||||
// When Repair wins, moves the ship toward its target and assigns that target to
|
||||
// each repair tool that has it in range. RepairSystem applies the healing and
|
||||
// does fallback acquisition for tools whose preferred target is out of range.
|
||||
class RepairExecutor
|
||||
{
|
||||
public:
|
||||
void execute(EntityAdmin& admin);
|
||||
};
|
||||
56
src/lib/ecs/system/ai/RetreatEvaluator.cpp
Normal file
56
src/lib/ecs/system/ai/RetreatEvaluator.cpp
Normal file
@@ -0,0 +1,56 @@
|
||||
#include "RetreatEvaluator.h"
|
||||
|
||||
#include <vector>
|
||||
|
||||
#include <QVector2D>
|
||||
|
||||
#include "AttackBehavior.h"
|
||||
#include "BehaviorScores.h"
|
||||
#include "EntityAdmin.h"
|
||||
#include "FactionComponent.h"
|
||||
#include "HealthComponent.h"
|
||||
#include "PositionComponent.h"
|
||||
#include "RetreatBehavior.h"
|
||||
#include "SensorRangeComponent.h"
|
||||
#include "ShipIdentityComponent.h"
|
||||
#include "tracing.h"
|
||||
|
||||
void RetreatEvaluator::evaluate(EntityAdmin& admin)
|
||||
{
|
||||
TRACE();
|
||||
|
||||
// Snapshot enemy ship positions for threat detection.
|
||||
std::vector<QVector2D> enemyShips;
|
||||
admin.forEach<ShipIdentityComponent, PositionComponent, FactionComponent>(
|
||||
[&enemyShips](entt::entity /*e*/, const ShipIdentityComponent& /*si*/,
|
||||
const PositionComponent& pos, const FactionComponent& f)
|
||||
{
|
||||
if (f.isEnemy) { enemyShips.push_back(pos.value); }
|
||||
});
|
||||
|
||||
admin.forEach<RetreatBehavior, PositionComponent, HealthComponent, SensorRangeComponent>(
|
||||
[&](entt::entity e, RetreatBehavior& retreat, const PositionComponent& pos,
|
||||
const HealthComponent& health, const SensorRangeComponent& sensor)
|
||||
{
|
||||
const bool lowHp = (health.maxHp > 0.0f)
|
||||
&& (health.hp / health.maxHp < retreat.retreatHpFraction);
|
||||
|
||||
bool threatened = false;
|
||||
const bool hasWeapons = admin.hasAll<AttackBehavior>(e);
|
||||
if (!hasWeapons)
|
||||
{
|
||||
for (const QVector2D& enemy : enemyShips)
|
||||
{
|
||||
if ((enemy - pos.value).length() <= sensor.value_tiles)
|
||||
{
|
||||
threatened = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
retreat.score = (lowHp || threatened)
|
||||
? BehaviorScores::kRetreat
|
||||
: BehaviorScores::kInactive;
|
||||
});
|
||||
}
|
||||
12
src/lib/ecs/system/ai/RetreatEvaluator.h
Normal file
12
src/lib/ecs/system/ai/RetreatEvaluator.h
Normal file
@@ -0,0 +1,12 @@
|
||||
#pragma once
|
||||
|
||||
class EntityAdmin;
|
||||
|
||||
// Scores high (above all task behaviors) when the ship's health is below its
|
||||
// retreat threshold, or when an enemy ship is within sensor range and the ship
|
||||
// has no weapons to fight back with.
|
||||
class RetreatEvaluator
|
||||
{
|
||||
public:
|
||||
void evaluate(EntityAdmin& admin);
|
||||
};
|
||||
20
src/lib/ecs/system/ai/RetreatExecutor.cpp
Normal file
20
src/lib/ecs/system/ai/RetreatExecutor.cpp
Normal file
@@ -0,0 +1,20 @@
|
||||
#include "RetreatExecutor.h"
|
||||
|
||||
#include "BehaviorKind.h"
|
||||
#include "EntityAdmin.h"
|
||||
#include "MovementIntentComponent.h"
|
||||
#include "RetreatBehavior.h"
|
||||
#include "SelectedBehaviorComponent.h"
|
||||
#include "tracing.h"
|
||||
|
||||
void RetreatExecutor::execute(EntityAdmin& admin)
|
||||
{
|
||||
TRACE();
|
||||
admin.forEach<RetreatBehavior, SelectedBehaviorComponent, MovementIntentComponent>(
|
||||
[](entt::entity /*e*/, const RetreatBehavior& retreat,
|
||||
const SelectedBehaviorComponent& selected, MovementIntentComponent& intent)
|
||||
{
|
||||
if (selected.winner != BehaviorKind::Retreat) { return; }
|
||||
intent = MovementIntentComponent{true, retreat.retreatPoint};
|
||||
});
|
||||
}
|
||||
10
src/lib/ecs/system/ai/RetreatExecutor.h
Normal file
10
src/lib/ecs/system/ai/RetreatExecutor.h
Normal file
@@ -0,0 +1,10 @@
|
||||
#pragma once
|
||||
|
||||
class EntityAdmin;
|
||||
|
||||
// Moves a ship to its retreat point (the rally point) when Retreat wins.
|
||||
class RetreatExecutor
|
||||
{
|
||||
public:
|
||||
void execute(EntityAdmin& admin);
|
||||
};
|
||||
55
src/lib/ecs/system/ai/SalvageScrapEvaluator.cpp
Normal file
55
src/lib/ecs/system/ai/SalvageScrapEvaluator.cpp
Normal file
@@ -0,0 +1,55 @@
|
||||
#include "SalvageScrapEvaluator.h"
|
||||
|
||||
#include <optional>
|
||||
#include <unordered_map>
|
||||
#include <vector>
|
||||
|
||||
#include <QVector2D>
|
||||
|
||||
#include "BehaviorScores.h"
|
||||
#include "BehaviorTargeting.h"
|
||||
#include "EntityAdmin.h"
|
||||
#include "PositionComponent.h"
|
||||
#include "SalvageScrapBehavior.h"
|
||||
#include "ScrapSystem.h"
|
||||
#include "SensorRangeComponent.h"
|
||||
#include "tracing.h"
|
||||
|
||||
void SalvageScrapEvaluator::evaluate(EntityAdmin& admin, const ScrapSystem& scraps)
|
||||
{
|
||||
TRACE();
|
||||
const std::unordered_map<entt::entity, CargoState> cargoByShip = buildCargoByShip(admin);
|
||||
const std::vector<ScrapInfo> allScrap = scraps.allScrapInfo();
|
||||
|
||||
admin.forEach<SalvageScrapBehavior, PositionComponent, SensorRangeComponent>(
|
||||
[&](entt::entity e, SalvageScrapBehavior& salvage, const PositionComponent& pos,
|
||||
const SensorRangeComponent& sensor)
|
||||
{
|
||||
const std::unordered_map<entt::entity, CargoState>::const_iterator it =
|
||||
cargoByShip.find(e);
|
||||
const bool cargoFull = (it != cargoByShip.end()) && isCargoFull(it->second);
|
||||
|
||||
if (cargoFull)
|
||||
{
|
||||
salvage.scrapTarget = std::nullopt;
|
||||
salvage.score = BehaviorScores::kInactive;
|
||||
return;
|
||||
}
|
||||
|
||||
// Find nearest scrap within sensor range.
|
||||
float bestDist = sensor.value_tiles;
|
||||
std::optional<QVector2D> bestPos;
|
||||
for (const ScrapInfo& si : allScrap)
|
||||
{
|
||||
const float dist = (si.position - pos.value).length();
|
||||
if (dist < bestDist)
|
||||
{
|
||||
bestDist = dist;
|
||||
bestPos = si.position;
|
||||
}
|
||||
}
|
||||
|
||||
salvage.scrapTarget = bestPos;
|
||||
salvage.score = bestPos ? BehaviorScores::kSalvage : BehaviorScores::kInactive;
|
||||
});
|
||||
}
|
||||
13
src/lib/ecs/system/ai/SalvageScrapEvaluator.h
Normal file
13
src/lib/ecs/system/ai/SalvageScrapEvaluator.h
Normal file
@@ -0,0 +1,13 @@
|
||||
#pragma once
|
||||
|
||||
class EntityAdmin;
|
||||
class ScrapSystem;
|
||||
|
||||
// When cargo is not full, finds the nearest scrap within sensor range and sets
|
||||
// it as the target, scoring high. Scores inactive when cargo is full or no scrap
|
||||
// is in range (Advance then handles roaming).
|
||||
class SalvageScrapEvaluator
|
||||
{
|
||||
public:
|
||||
void evaluate(EntityAdmin& admin, const ScrapSystem& scraps);
|
||||
};
|
||||
21
src/lib/ecs/system/ai/SalvageScrapExecutor.cpp
Normal file
21
src/lib/ecs/system/ai/SalvageScrapExecutor.cpp
Normal file
@@ -0,0 +1,21 @@
|
||||
#include "SalvageScrapExecutor.h"
|
||||
|
||||
#include "BehaviorKind.h"
|
||||
#include "EntityAdmin.h"
|
||||
#include "MovementIntentComponent.h"
|
||||
#include "SalvageScrapBehavior.h"
|
||||
#include "SelectedBehaviorComponent.h"
|
||||
#include "tracing.h"
|
||||
|
||||
void SalvageScrapExecutor::execute(EntityAdmin& admin)
|
||||
{
|
||||
TRACE();
|
||||
admin.forEach<SalvageScrapBehavior, SelectedBehaviorComponent, MovementIntentComponent>(
|
||||
[](entt::entity /*e*/, const SalvageScrapBehavior& salvage,
|
||||
const SelectedBehaviorComponent& selected, MovementIntentComponent& intent)
|
||||
{
|
||||
if (selected.winner != BehaviorKind::SalvageScrap) { return; }
|
||||
if (!salvage.scrapTarget) { return; }
|
||||
intent = MovementIntentComponent{true, *salvage.scrapTarget};
|
||||
});
|
||||
}
|
||||
10
src/lib/ecs/system/ai/SalvageScrapExecutor.h
Normal file
10
src/lib/ecs/system/ai/SalvageScrapExecutor.h
Normal file
@@ -0,0 +1,10 @@
|
||||
#pragma once
|
||||
|
||||
class EntityAdmin;
|
||||
|
||||
// Moves a ship toward its scrap target when SalvageScrap is the winning behavior.
|
||||
class SalvageScrapExecutor
|
||||
{
|
||||
public:
|
||||
void execute(EntityAdmin& admin);
|
||||
};
|
||||
@@ -14,6 +14,8 @@
|
||||
#include "ModuleOwnerComponent.h"
|
||||
#include "MovementIntentSystem.h"
|
||||
#include "PositionComponent.h"
|
||||
#include "RepairSystem.h"
|
||||
#include "SalvagerSystem.h"
|
||||
#include "ScrapSystem.h"
|
||||
#include "ShipIdentityComponent.h"
|
||||
#include "ShipSystem.h"
|
||||
@@ -68,6 +70,8 @@ Simulation::Simulation(GameConfig config, unsigned int seed)
|
||||
m_movementIntentSystem = std::make_unique<MovementIntentSystem>();
|
||||
m_dynamicBodySystem = std::make_unique<DynamicBodySystem>();
|
||||
m_scrapSystem = std::make_unique<ScrapSystem>(m_admin);
|
||||
m_salvagerSystem = std::make_unique<SalvagerSystem>(m_admin);
|
||||
m_repairSystem = std::make_unique<RepairSystem>(m_admin);
|
||||
m_waveSystem = std::make_unique<WaveSystem>(m_config, m_rng);
|
||||
m_combatSystem = std::make_unique<CombatSystem>(m_config);
|
||||
|
||||
@@ -169,6 +173,8 @@ void Simulation::reset(unsigned int seed)
|
||||
m_movementIntentSystem = std::make_unique<MovementIntentSystem>();
|
||||
m_dynamicBodySystem = std::make_unique<DynamicBodySystem>();
|
||||
m_scrapSystem = std::make_unique<ScrapSystem>(m_admin);
|
||||
m_salvagerSystem = std::make_unique<SalvagerSystem>(m_admin);
|
||||
m_repairSystem = std::make_unique<RepairSystem>(m_admin);
|
||||
m_waveSystem = std::make_unique<WaveSystem>(m_config, m_rng);
|
||||
m_combatSystem = std::make_unique<CombatSystem>(m_config);
|
||||
|
||||
@@ -238,11 +244,12 @@ void Simulation::tick()
|
||||
}
|
||||
|
||||
m_shipSystem->clearMovementIntents();
|
||||
m_aiSystem->tickHomeReturnBehavior(m_admin); // priority 4
|
||||
m_aiSystem->tickThreatResponseBehavior(m_admin, *m_buildingSystem); // priority 3
|
||||
m_aiSystem->tickRepairBehavior(m_admin, *m_buildingSystem); // priority 2
|
||||
m_aiSystem->tickRepairTools(m_admin);
|
||||
m_aiSystem->tickSalvageBehavior(m_admin, *m_scrapSystem, *m_buildingSystem); // priority 1
|
||||
// Score-based behavior selection: evaluate, select winner, execute (sets
|
||||
// movement intent + preferred module targets only — no world mutation).
|
||||
m_aiSystem->tick(m_admin, *m_buildingSystem, *m_scrapSystem);
|
||||
// Module systems perform the world mutation (collection/delivery, healing).
|
||||
m_salvagerSystem->tick(*m_scrapSystem, *m_buildingSystem);
|
||||
m_repairSystem->tick();
|
||||
|
||||
// Step 8: combat resolution
|
||||
m_combatSystem->tick(m_currentTick, m_admin,
|
||||
|
||||
@@ -27,6 +27,8 @@ class BuildingSystem;
|
||||
class CombatSystem;
|
||||
class DynamicBodySystem;
|
||||
class MovementIntentSystem;
|
||||
class RepairSystem;
|
||||
class SalvagerSystem;
|
||||
class ShipSystem;
|
||||
class ScrapSystem;
|
||||
class WaveSystem;
|
||||
@@ -185,6 +187,8 @@ private:
|
||||
std::unique_ptr<MovementIntentSystem> m_movementIntentSystem;
|
||||
std::unique_ptr<DynamicBodySystem> m_dynamicBodySystem;
|
||||
std::unique_ptr<ScrapSystem> m_scrapSystem;
|
||||
std::unique_ptr<SalvagerSystem> m_salvagerSystem;
|
||||
std::unique_ptr<RepairSystem> m_repairSystem;
|
||||
std::unique_ptr<WaveSystem> m_waveSystem;
|
||||
std::unique_ptr<CombatSystem> m_combatSystem;
|
||||
|
||||
|
||||
@@ -5,35 +5,40 @@
|
||||
#include <QPoint>
|
||||
#include <QVector2D>
|
||||
|
||||
#include "AdvanceBehavior.h"
|
||||
#include "AiSystem.h"
|
||||
#include "AttackBehavior.h"
|
||||
#include "BehaviorKind.h"
|
||||
#include "BeltSystem.h"
|
||||
#include "ModuleOwnerComponent.h"
|
||||
#include "ShipLayout.h"
|
||||
#include "Building.h"
|
||||
#include "BuildingSystem.h"
|
||||
#include "BuildingType.h"
|
||||
#include "ConfigLoader.h"
|
||||
#include "DeliverScrapBehavior.h"
|
||||
#include "DynamicBodyComponent.h"
|
||||
#include "DynamicBodySystem.h"
|
||||
#include "EntityAdmin.h"
|
||||
#include "FactionComponent.h"
|
||||
#include "HealthComponent.h"
|
||||
#include "HomeReturnBehaviorComponent.h"
|
||||
#include "ModuleOwnerComponent.h"
|
||||
#include "MovementIntentComponent.h"
|
||||
#include "MovementIntentSystem.h"
|
||||
#include "PositionComponent.h"
|
||||
#include "RallyBehaviorComponent.h"
|
||||
#include "RepairBehaviorComponent.h"
|
||||
#include "RepairBehavior.h"
|
||||
#include "RepairSystem.h"
|
||||
#include "RepairToolComponent.h"
|
||||
#include "RetreatBehavior.h"
|
||||
#include "Rotation.h"
|
||||
#include "SalvageBehaviorComponent.h"
|
||||
#include "SalvageCargoComponent.h"
|
||||
#include "SalvageScrapBehavior.h"
|
||||
#include "SalvagerSystem.h"
|
||||
#include "ScrapSystem.h"
|
||||
#include "SelectedBehaviorComponent.h"
|
||||
#include "SensorRangeComponent.h"
|
||||
#include "ShipIdentityComponent.h"
|
||||
#include "ShipLayout.h"
|
||||
#include "ShipSystem.h"
|
||||
#include "Tick.h"
|
||||
#include "ThreatResponseBehaviorComponent.h"
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Fixture
|
||||
@@ -55,6 +60,8 @@ struct Fixture
|
||||
BuildingSystem buildings;
|
||||
ShipSystem ships;
|
||||
AiSystem ai;
|
||||
SalvagerSystem salvager;
|
||||
RepairSystem repair;
|
||||
MovementIntentSystem movementIntent;
|
||||
DynamicBodySystem dynamicBody;
|
||||
ScrapSystem scraps;
|
||||
@@ -73,20 +80,32 @@ struct Fixture
|
||||
[](const std::string&) -> bool { return true; },
|
||||
rng)
|
||||
, ships(cfg, admin)
|
||||
, salvager(admin)
|
||||
, repair(admin)
|
||||
, scraps(admin)
|
||||
, tick(0)
|
||||
{
|
||||
}
|
||||
|
||||
// Phase 1-3: clear intents, evaluate behaviors, select winners, execute.
|
||||
void decide()
|
||||
{
|
||||
ships.clearMovementIntents();
|
||||
ai.tick(admin, buildings, scraps);
|
||||
}
|
||||
|
||||
// World mutation: collection/delivery and healing.
|
||||
void runModules()
|
||||
{
|
||||
salvager.tick(scraps, buildings);
|
||||
repair.tick();
|
||||
}
|
||||
|
||||
// Run one full behavior+movement tick (steps 7 and 10).
|
||||
void runBehaviorTick()
|
||||
{
|
||||
ships.clearMovementIntents();
|
||||
ai.tickHomeReturnBehavior(admin);
|
||||
ai.tickThreatResponseBehavior(admin, buildings);
|
||||
ai.tickRepairBehavior(admin, buildings);
|
||||
ai.tickRepairTools(admin);
|
||||
ai.tickSalvageBehavior(admin, scraps, buildings);
|
||||
decide();
|
||||
runModules();
|
||||
movementIntent.tick(admin);
|
||||
dynamicBody.tick(admin);
|
||||
++tick;
|
||||
@@ -131,7 +150,6 @@ static entt::entity firstSalvageChild(EntityAdmin& admin, entt::entity ship)
|
||||
return result;
|
||||
}
|
||||
|
||||
// Helpers to read ECS data for a ship entity.
|
||||
static entt::entity firstRepairChild(EntityAdmin& admin, entt::entity ship)
|
||||
{
|
||||
entt::entity result = entt::null;
|
||||
@@ -159,6 +177,11 @@ static const MovementIntentComponent& intent(EntityAdmin& a, entt::entity e)
|
||||
return a.get<MovementIntentComponent>(e);
|
||||
}
|
||||
|
||||
static BehaviorKind winnerOf(EntityAdmin& a, entt::entity e)
|
||||
{
|
||||
return a.get<SelectedBehaviorComponent>(e).winner;
|
||||
}
|
||||
|
||||
static const HealthComponent& health(EntityAdmin& a, entt::entity e)
|
||||
{
|
||||
return a.get<HealthComponent>(e);
|
||||
@@ -173,16 +196,16 @@ static const PositionComponent& pos(EntityAdmin& a, entt::entity e)
|
||||
// clearMovementIntents
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
TEST_CASE("BehaviorSystem: clearMovementIntents resets all ships to priority 0",
|
||||
TEST_CASE("BehaviorSystem: clearMovementIntents resets all ships to inactive",
|
||||
"[behavior]")
|
||||
{
|
||||
Fixture f;
|
||||
const entt::entity e = f.ships.spawn("interceptor", 1, QVector2D(0.0f, 0.0f));
|
||||
|
||||
f.admin.get<MovementIntentComponent>(e) = MovementIntentComponent{3, QVector2D(10.0f, 0.0f)};
|
||||
f.admin.get<MovementIntentComponent>(e) = MovementIntentComponent{true, QVector2D(10.0f, 0.0f)};
|
||||
f.ships.clearMovementIntents();
|
||||
|
||||
REQUIRE(intent(f.admin, e).priority == 0);
|
||||
REQUIRE_FALSE(intent(f.admin, e).active);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -196,7 +219,7 @@ TEST_CASE("BehaviorSystem: tickMovement advances ship by maxSpeed_tpt toward tar
|
||||
const entt::entity e = f.ships.spawn("interceptor", 1, QVector2D(0.0f, 0.0f));
|
||||
|
||||
const float speed = f.admin.get<DynamicBodyComponent>(e).maxSpeed_tpt;
|
||||
f.admin.get<MovementIntentComponent>(e) = MovementIntentComponent{1, QVector2D(100.0f, 0.0f)};
|
||||
f.admin.get<MovementIntentComponent>(e) = MovementIntentComponent{true, QVector2D(100.0f, 0.0f)};
|
||||
f.movementIntent.tick(f.admin);
|
||||
f.dynamicBody.tick(f.admin);
|
||||
|
||||
@@ -212,7 +235,7 @@ TEST_CASE("BehaviorSystem: tickMovement stops exactly at target without overshoo
|
||||
|
||||
const float speed = f.admin.get<DynamicBodyComponent>(e).maxSpeed_tpt;
|
||||
const QVector2D target(speed * 0.5f, 0.0f);
|
||||
f.admin.get<MovementIntentComponent>(e) = MovementIntentComponent{1, target};
|
||||
f.admin.get<MovementIntentComponent>(e) = MovementIntentComponent{true, target};
|
||||
f.movementIntent.tick(f.admin);
|
||||
f.dynamicBody.tick(f.admin);
|
||||
|
||||
@@ -221,60 +244,65 @@ TEST_CASE("BehaviorSystem: tickMovement stops exactly at target without overshoo
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// tickHomeReturnBehavior
|
||||
// RetreatBehavior
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
TEST_CASE("BehaviorSystem: tickHomeReturnBehavior does nothing when HP is above threshold",
|
||||
"[behavior]")
|
||||
TEST_CASE("BehaviorSystem: healthy player ship does not retreat", "[behavior]")
|
||||
{
|
||||
Fixture f;
|
||||
const entt::entity e = f.ships.spawn("interceptor", 1, QVector2D(0.0f, 0.0f));
|
||||
f.admin.addComponent<HomeReturnBehaviorComponent>(e, HomeReturnBehaviorComponent{0.3f, QVector2D(-10.0f, 0.0f)});
|
||||
f.admin.get<HealthComponent>(e).hp = f.admin.get<HealthComponent>(e).maxHp; // full HP
|
||||
|
||||
f.ships.clearMovementIntents();
|
||||
f.ai.tickHomeReturnBehavior(f.admin);
|
||||
f.decide();
|
||||
|
||||
REQUIRE(intent(f.admin, e).priority == 0);
|
||||
REQUIRE(winnerOf(f.admin, e) != BehaviorKind::Retreat);
|
||||
}
|
||||
|
||||
TEST_CASE("BehaviorSystem: tickHomeReturnBehavior writes priority-4 intent toward homePos when HP is low",
|
||||
"[behavior]")
|
||||
TEST_CASE("BehaviorSystem: low-HP player ship retreats toward the rally point", "[behavior]")
|
||||
{
|
||||
Fixture f;
|
||||
const QVector2D rallyPoint(-50.0f, 0.0f);
|
||||
f.ships.setRallyPoint(rallyPoint);
|
||||
const entt::entity e = f.ships.spawn("interceptor", 1, QVector2D(0.0f, 0.0f));
|
||||
const QVector2D homePos(-10.0f, 0.0f);
|
||||
f.admin.addComponent<HomeReturnBehaviorComponent>(e, HomeReturnBehaviorComponent{0.5f, homePos});
|
||||
f.admin.get<HealthComponent>(e).hp = f.admin.get<HealthComponent>(e).maxHp * 0.2f; // below threshold
|
||||
|
||||
f.ships.clearMovementIntents();
|
||||
f.ai.tickHomeReturnBehavior(f.admin);
|
||||
f.decide();
|
||||
|
||||
REQUIRE(intent(f.admin, e).priority == 4);
|
||||
REQUIRE(intent(f.admin, e).target.x() == Approx(homePos.x()));
|
||||
REQUIRE(winnerOf(f.admin, e) == BehaviorKind::Retreat);
|
||||
REQUIRE(intent(f.admin, e).active);
|
||||
REQUIRE(intent(f.admin, e).target.x() == Approx(rallyPoint.x()));
|
||||
}
|
||||
|
||||
TEST_CASE("BehaviorSystem: tickHomeReturnBehavior priority-4 beats tickThreatResponseBehavior priority-3",
|
||||
"[behavior]")
|
||||
TEST_CASE("BehaviorSystem: low-HP retreat outranks attacking a nearby enemy", "[behavior]")
|
||||
{
|
||||
Fixture f;
|
||||
const QVector2D rallyPoint(-50.0f, 0.0f);
|
||||
f.ships.setRallyPoint(rallyPoint);
|
||||
const entt::entity player = 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.admin.addComponent<HomeReturnBehaviorComponent>(player, HomeReturnBehaviorComponent{0.5f, homePos});
|
||||
f.admin.get<HealthComponent>(player).hp = f.admin.get<HealthComponent>(player).maxHp * 0.1f;
|
||||
|
||||
f.ships.clearMovementIntents();
|
||||
f.ai.tickHomeReturnBehavior(f.admin);
|
||||
f.ai.tickThreatResponseBehavior(f.admin, f.buildings);
|
||||
f.decide();
|
||||
|
||||
REQUIRE(intent(f.admin, player).priority == 4);
|
||||
REQUIRE(intent(f.admin, player).target.x() == Approx(homePos.x()));
|
||||
REQUIRE(winnerOf(f.admin, player) == BehaviorKind::Retreat);
|
||||
REQUIRE(intent(f.admin, player).target.x() == Approx(rallyPoint.x()));
|
||||
}
|
||||
|
||||
TEST_CASE("BehaviorSystem: enemy ships never retreat even at low HP", "[behavior]")
|
||||
{
|
||||
Fixture f;
|
||||
const entt::entity enemy = f.ships.spawn("interceptor", 1, QVector2D(0.0f, 0.0f),
|
||||
/*isEnemy=*/true);
|
||||
f.admin.get<HealthComponent>(enemy).hp = f.admin.get<HealthComponent>(enemy).maxHp * 0.05f;
|
||||
|
||||
f.decide();
|
||||
|
||||
REQUIRE_FALSE(f.admin.hasAll<RetreatBehavior>(enemy));
|
||||
REQUIRE(winnerOf(f.admin, enemy) != BehaviorKind::Retreat);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// tickThreatResponseBehavior — player ships
|
||||
// AttackBehavior — player ships
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
TEST_CASE("BehaviorSystem: player combat ship acquires nearest enemy ship in range",
|
||||
@@ -285,13 +313,13 @@ TEST_CASE("BehaviorSystem: player combat ship acquires nearest enemy ship in ran
|
||||
const entt::entity enemy = f.ships.spawn("interceptor", 1, QVector2D(10.0f, 0.0f),
|
||||
/*isEnemy=*/true);
|
||||
|
||||
f.ships.clearMovementIntents();
|
||||
f.ai.tickThreatResponseBehavior(f.admin, f.buildings);
|
||||
f.decide();
|
||||
|
||||
REQUIRE(f.admin.hasAll<ThreatResponseBehaviorComponent>(player));
|
||||
const ThreatResponseBehaviorComponent& threatResponseBehavior = f.admin.get<ThreatResponseBehaviorComponent>(player);
|
||||
REQUIRE(threatResponseBehavior.currentTarget.has_value());
|
||||
REQUIRE(*threatResponseBehavior.currentTarget == enemy);
|
||||
REQUIRE(f.admin.hasAll<AttackBehavior>(player));
|
||||
const AttackBehavior& attack = f.admin.get<AttackBehavior>(player);
|
||||
REQUIRE(attack.currentTarget.has_value());
|
||||
REQUIRE(*attack.currentTarget == enemy);
|
||||
REQUIRE(winnerOf(f.admin, player) == BehaviorKind::Attack);
|
||||
}
|
||||
|
||||
TEST_CASE("BehaviorSystem: player combat ship does not target friendly ships",
|
||||
@@ -301,11 +329,11 @@ TEST_CASE("BehaviorSystem: player combat ship does not target friendly ships",
|
||||
const entt::entity e1 = f.ships.spawn("interceptor", 1, QVector2D(0.0f, 0.0f));
|
||||
f.ships.spawn("interceptor", 1, QVector2D(5.0f, 0.0f)); // also player
|
||||
|
||||
f.ships.clearMovementIntents();
|
||||
f.ai.tickThreatResponseBehavior(f.admin, f.buildings);
|
||||
f.decide();
|
||||
|
||||
REQUIRE(f.admin.hasAll<ThreatResponseBehaviorComponent>(e1));
|
||||
REQUIRE_FALSE(f.admin.get<ThreatResponseBehaviorComponent>(e1).currentTarget.has_value());
|
||||
REQUIRE(f.admin.hasAll<AttackBehavior>(e1));
|
||||
REQUIRE_FALSE(f.admin.get<AttackBehavior>(e1).currentTarget.has_value());
|
||||
REQUIRE(winnerOf(f.admin, e1) != BehaviorKind::Attack);
|
||||
}
|
||||
|
||||
TEST_CASE("BehaviorSystem: player combat ship ignores enemy beyond engagement range",
|
||||
@@ -315,14 +343,13 @@ TEST_CASE("BehaviorSystem: player combat ship ignores enemy beyond engagement ra
|
||||
const entt::entity player = f.ships.spawn("interceptor", 1, QVector2D(0.0f, 0.0f));
|
||||
f.ships.spawn("interceptor", 1, QVector2D(500.0f, 0.0f), /*isEnemy=*/true);
|
||||
|
||||
f.ships.clearMovementIntents();
|
||||
f.ai.tickThreatResponseBehavior(f.admin, f.buildings);
|
||||
f.decide();
|
||||
|
||||
REQUIRE_FALSE(f.admin.get<ThreatResponseBehaviorComponent>(player).currentTarget.has_value());
|
||||
REQUIRE_FALSE(f.admin.get<AttackBehavior>(player).currentTarget.has_value());
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// tickThreatResponseBehavior — enemy ships
|
||||
// AttackBehavior — enemy ships
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
TEST_CASE("BehaviorSystem: enemy ship acquires nearest player ship in range",
|
||||
@@ -333,34 +360,34 @@ TEST_CASE("BehaviorSystem: enemy ship acquires nearest player ship in range",
|
||||
const entt::entity enemy = f.ships.spawn("interceptor", 1, QVector2D(10.0f, 0.0f),
|
||||
/*isEnemy=*/true);
|
||||
|
||||
f.ships.clearMovementIntents();
|
||||
f.ai.tickThreatResponseBehavior(f.admin, f.buildings);
|
||||
f.decide();
|
||||
|
||||
REQUIRE(f.admin.hasAll<ThreatResponseBehaviorComponent>(enemy));
|
||||
const ThreatResponseBehaviorComponent& threatResponseBehavior = f.admin.get<ThreatResponseBehaviorComponent>(enemy);
|
||||
REQUIRE(threatResponseBehavior.currentTarget.has_value());
|
||||
REQUIRE(*threatResponseBehavior.currentTarget == player);
|
||||
REQUIRE(f.admin.hasAll<AttackBehavior>(enemy));
|
||||
const AttackBehavior& attack = f.admin.get<AttackBehavior>(enemy);
|
||||
REQUIRE(attack.currentTarget.has_value());
|
||||
REQUIRE(*attack.currentTarget == player);
|
||||
REQUIRE(winnerOf(f.admin, enemy) == BehaviorKind::Attack);
|
||||
}
|
||||
|
||||
TEST_CASE("BehaviorSystem: enemy ship with no target writes leftward movement intent",
|
||||
TEST_CASE("BehaviorSystem: enemy ship with no target advances leftward",
|
||||
"[behavior]")
|
||||
{
|
||||
Fixture f;
|
||||
const entt::entity enemy = f.ships.spawn("interceptor", 1, QVector2D(100.0f, 0.0f),
|
||||
/*isEnemy=*/true);
|
||||
|
||||
f.ships.clearMovementIntents();
|
||||
f.ai.tickThreatResponseBehavior(f.admin, f.buildings);
|
||||
f.decide();
|
||||
|
||||
REQUIRE(intent(f.admin, enemy).priority == 3);
|
||||
REQUIRE(winnerOf(f.admin, enemy) == BehaviorKind::Advance);
|
||||
REQUIRE(intent(f.admin, enemy).active);
|
||||
REQUIRE(intent(f.admin, enemy).target.x() < 0.0f);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// tickRepairBehavior
|
||||
// RepairBehavior
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
TEST_CASE("BehaviorSystem: repair ship writes intent toward damaged friendly ship",
|
||||
TEST_CASE("BehaviorSystem: repair ship moves toward damaged friendly ship",
|
||||
"[behavior]")
|
||||
{
|
||||
Fixture f;
|
||||
@@ -371,10 +398,10 @@ TEST_CASE("BehaviorSystem: repair ship writes intent toward damaged friendly shi
|
||||
|
||||
f.admin.get<HealthComponent>(friendly).hp = f.admin.get<HealthComponent>(friendly).maxHp * 0.5f;
|
||||
|
||||
f.ships.clearMovementIntents();
|
||||
f.ai.tickRepairBehavior(f.admin, f.buildings);
|
||||
f.decide();
|
||||
|
||||
REQUIRE(intent(f.admin, repairShip).priority == 2);
|
||||
REQUIRE(winnerOf(f.admin, repairShip) == BehaviorKind::Repair);
|
||||
REQUIRE(intent(f.admin, repairShip).active);
|
||||
REQUIRE(intent(f.admin, repairShip).target.x() == Approx(5.0f));
|
||||
}
|
||||
|
||||
@@ -383,16 +410,14 @@ TEST_CASE("BehaviorSystem: repair ship heals damaged ally within repair range",
|
||||
{
|
||||
Fixture f;
|
||||
const ShipLayoutConfig repairLayout = makeSingleModuleLayout("repair_tool");
|
||||
const entt::entity repairShip = f.ships.spawn("repair_ship", 1, QVector2D(0.0f, 0.0f),
|
||||
false, repairLayout);
|
||||
f.ships.spawn("repair_ship", 1, QVector2D(0.0f, 0.0f), false, repairLayout);
|
||||
const entt::entity friendly = f.ships.spawn("interceptor", 1, QVector2D(1.0f, 0.0f));
|
||||
|
||||
const float initialHp = f.admin.get<HealthComponent>(friendly).maxHp * 0.5f;
|
||||
f.admin.get<HealthComponent>(friendly).hp = initialHp;
|
||||
|
||||
f.ships.clearMovementIntents();
|
||||
f.ai.tickRepairBehavior(f.admin, f.buildings);
|
||||
f.ai.tickRepairTools(f.admin);
|
||||
f.decide();
|
||||
f.runModules();
|
||||
|
||||
REQUIRE(health(f.admin, friendly).hp > initialHp);
|
||||
}
|
||||
@@ -408,9 +433,8 @@ TEST_CASE("BehaviorSystem: repair ship does not heal above maxHp", "[behavior]")
|
||||
|
||||
for (int i = 0; i < 5; ++i)
|
||||
{
|
||||
f.ships.clearMovementIntents();
|
||||
f.ai.tickRepairBehavior(f.admin, f.buildings);
|
||||
f.ai.tickRepairTools(f.admin);
|
||||
f.decide();
|
||||
f.runModules();
|
||||
}
|
||||
|
||||
const HealthComponent& h = health(f.admin, friendly);
|
||||
@@ -419,10 +443,10 @@ TEST_CASE("BehaviorSystem: repair ship does not heal above maxHp", "[behavior]")
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// tickRepairTools — per-module targeting
|
||||
// RepairSystem — per-module targeting
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
TEST_CASE("BehaviorSystem: rt.currentTarget is set to preferred target when in range and damaged",
|
||||
TEST_CASE("RepairSystem: tool heals the in-range damaged target chosen by the executor",
|
||||
"[behavior]")
|
||||
{
|
||||
Fixture f;
|
||||
@@ -431,118 +455,117 @@ TEST_CASE("BehaviorSystem: rt.currentTarget is set to preferred target when in r
|
||||
false, repairLayout);
|
||||
const entt::entity friendly = f.ships.spawn("interceptor", 1, QVector2D(10.0f, 0.0f));
|
||||
|
||||
f.admin.get<HealthComponent>(friendly).hp = f.admin.get<HealthComponent>(friendly).maxHp * 0.5f;
|
||||
const float initHp = f.admin.get<HealthComponent>(friendly).maxHp * 0.5f;
|
||||
f.admin.get<HealthComponent>(friendly).hp = initHp;
|
||||
|
||||
f.ships.clearMovementIntents();
|
||||
f.ai.tickRepairBehavior(f.admin, f.buildings);
|
||||
f.ai.tickRepairTools(f.admin);
|
||||
f.decide();
|
||||
f.runModules();
|
||||
|
||||
const entt::entity rc = firstRepairChild(f.admin, repairShip);
|
||||
REQUIRE(f.admin.isValid(rc));
|
||||
REQUIRE(f.admin.get<RepairToolComponent>(rc).currentTarget.has_value());
|
||||
REQUIRE(*f.admin.get<RepairToolComponent>(rc).currentTarget == friendly);
|
||||
REQUIRE(health(f.admin, friendly).hp > f.admin.get<HealthComponent>(friendly).maxHp * 0.5f);
|
||||
REQUIRE(health(f.admin, friendly).hp > initHp);
|
||||
}
|
||||
|
||||
TEST_CASE("BehaviorSystem: repair module falls back to in-range target when preferred is out of repair range",
|
||||
TEST_CASE("RepairSystem: tool falls back to in-range target when its target is out of repair range",
|
||||
"[behavior]")
|
||||
{
|
||||
Fixture f;
|
||||
const ShipLayoutConfig repairLayout = makeSingleModuleLayout("repair_tool");
|
||||
const entt::entity repairShip = f.ships.spawn("repair_ship", 1, QVector2D(0.0f, 0.0f),
|
||||
false, repairLayout);
|
||||
// preferred: within sensor range (200) but beyond repair range (80)
|
||||
const entt::entity preferred = f.ships.spawn("interceptor", 1, QVector2D(90.0f, 0.0f));
|
||||
// fallback: within repair range
|
||||
const entt::entity fallback = f.ships.spawn("interceptor", 1, QVector2D(20.0f, 0.0f));
|
||||
// out of repair range (80) but in sensor range (200)
|
||||
const entt::entity outOfRange = f.ships.spawn("interceptor", 1, QVector2D(90.0f, 0.0f));
|
||||
// within repair range
|
||||
const entt::entity fallback = f.ships.spawn("interceptor", 1, QVector2D(20.0f, 0.0f));
|
||||
|
||||
const float preferredInitHp = f.admin.get<HealthComponent>(preferred).maxHp * 0.5f;
|
||||
const float fallbackInitHp = f.admin.get<HealthComponent>(fallback).maxHp * 0.5f;
|
||||
f.admin.get<HealthComponent>(preferred).hp = preferredInitHp;
|
||||
f.admin.get<HealthComponent>(fallback).hp = fallbackInitHp;
|
||||
|
||||
// Force preferred as nav target without running full behavior tick.
|
||||
f.admin.get<RepairBehaviorComponent>(repairShip).currentTarget = preferred;
|
||||
|
||||
f.ai.tickRepairTools(f.admin);
|
||||
const float outInitHp = f.admin.get<HealthComponent>(outOfRange).maxHp * 0.5f;
|
||||
const float fallbackInitHp = f.admin.get<HealthComponent>(fallback).maxHp * 0.5f;
|
||||
f.admin.get<HealthComponent>(outOfRange).hp = outInitHp;
|
||||
f.admin.get<HealthComponent>(fallback).hp = fallbackInitHp;
|
||||
|
||||
// Seed the tool with an out-of-range target; RepairSystem must reacquire.
|
||||
const entt::entity rc = firstRepairChild(f.admin, repairShip);
|
||||
f.admin.get<RepairToolComponent>(rc).currentTarget = outOfRange;
|
||||
|
||||
f.repair.tick();
|
||||
|
||||
REQUIRE(f.admin.get<RepairToolComponent>(rc).currentTarget.has_value());
|
||||
REQUIRE(*f.admin.get<RepairToolComponent>(rc).currentTarget == fallback);
|
||||
REQUIRE(health(f.admin, fallback).hp > fallbackInitHp);
|
||||
REQUIRE(health(f.admin, preferred).hp == Approx(preferredInitHp));
|
||||
REQUIRE(health(f.admin, outOfRange).hp == Approx(outInitHp));
|
||||
}
|
||||
|
||||
TEST_CASE("BehaviorSystem: repair module falls back when preferred target is fully healed",
|
||||
TEST_CASE("RepairSystem: tool falls back when its target is fully healed",
|
||||
"[behavior]")
|
||||
{
|
||||
Fixture f;
|
||||
const ShipLayoutConfig repairLayout = makeSingleModuleLayout("repair_tool");
|
||||
const entt::entity repairShip = f.ships.spawn("repair_ship", 1, QVector2D(0.0f, 0.0f),
|
||||
false, repairLayout);
|
||||
const entt::entity preferred = f.ships.spawn("interceptor", 1, QVector2D(10.0f, 0.0f));
|
||||
const entt::entity fallback = f.ships.spawn("interceptor", 1, QVector2D(15.0f, 0.0f));
|
||||
const entt::entity healed = f.ships.spawn("interceptor", 1, QVector2D(10.0f, 0.0f));
|
||||
const entt::entity fallback = f.ships.spawn("interceptor", 1, QVector2D(15.0f, 0.0f));
|
||||
|
||||
// preferred is at full HP; only fallback needs repair
|
||||
f.admin.get<HealthComponent>(preferred).hp = f.admin.get<HealthComponent>(preferred).maxHp;
|
||||
f.admin.get<HealthComponent>(healed).hp = f.admin.get<HealthComponent>(healed).maxHp;
|
||||
const float fallbackInitHp = f.admin.get<HealthComponent>(fallback).maxHp * 0.5f;
|
||||
f.admin.get<HealthComponent>(fallback).hp = fallbackInitHp;
|
||||
|
||||
f.admin.get<RepairBehaviorComponent>(repairShip).currentTarget = preferred;
|
||||
|
||||
f.ai.tickRepairTools(f.admin);
|
||||
|
||||
const entt::entity rc = firstRepairChild(f.admin, repairShip);
|
||||
f.admin.get<RepairToolComponent>(rc).currentTarget = healed;
|
||||
|
||||
f.repair.tick();
|
||||
|
||||
REQUIRE(*f.admin.get<RepairToolComponent>(rc).currentTarget == fallback);
|
||||
REQUIRE(health(f.admin, fallback).hp > fallbackInitHp);
|
||||
}
|
||||
|
||||
TEST_CASE("BehaviorSystem: repair module falls back when preferred target is destroyed",
|
||||
TEST_CASE("RepairSystem: tool falls back when its target is destroyed",
|
||||
"[behavior]")
|
||||
{
|
||||
Fixture f;
|
||||
const ShipLayoutConfig repairLayout = makeSingleModuleLayout("repair_tool");
|
||||
const entt::entity repairShip = f.ships.spawn("repair_ship", 1, QVector2D(0.0f, 0.0f),
|
||||
false, repairLayout);
|
||||
const entt::entity preferred = f.ships.spawn("interceptor", 1, QVector2D(10.0f, 0.0f));
|
||||
const entt::entity fallback = f.ships.spawn("interceptor", 1, QVector2D(15.0f, 0.0f));
|
||||
const entt::entity gone = f.ships.spawn("interceptor", 1, QVector2D(10.0f, 0.0f));
|
||||
const entt::entity fallback = f.ships.spawn("interceptor", 1, QVector2D(15.0f, 0.0f));
|
||||
|
||||
const float fallbackInitHp = f.admin.get<HealthComponent>(fallback).maxHp * 0.5f;
|
||||
f.admin.get<HealthComponent>(fallback).hp = fallbackInitHp;
|
||||
|
||||
f.admin.get<RepairBehaviorComponent>(repairShip).currentTarget = preferred;
|
||||
f.ships.despawn(preferred);
|
||||
|
||||
f.ai.tickRepairTools(f.admin);
|
||||
|
||||
const entt::entity rc = firstRepairChild(f.admin, repairShip);
|
||||
f.admin.get<RepairToolComponent>(rc).currentTarget = gone;
|
||||
f.ships.despawn(gone);
|
||||
|
||||
f.repair.tick();
|
||||
|
||||
REQUIRE(*f.admin.get<RepairToolComponent>(rc).currentTarget == fallback);
|
||||
REQUIRE(health(f.admin, fallback).hp > fallbackInitHp);
|
||||
}
|
||||
|
||||
TEST_CASE("BehaviorSystem: rt.currentTarget is cleared when no repairable target is in range",
|
||||
TEST_CASE("RepairSystem: tool target is cleared when no repairable target is in range",
|
||||
"[behavior]")
|
||||
{
|
||||
Fixture f;
|
||||
const ShipLayoutConfig repairLayout = makeSingleModuleLayout("repair_tool");
|
||||
const entt::entity repairShip = f.ships.spawn("repair_ship", 1, QVector2D(0.0f, 0.0f),
|
||||
false, repairLayout);
|
||||
// friendly is beyond repair range (80) but within sensor range (200)
|
||||
// damaged but beyond repair range (80)
|
||||
const entt::entity outOfRange = f.ships.spawn("interceptor", 1, QVector2D(150.0f, 0.0f));
|
||||
|
||||
const float initHp = f.admin.get<HealthComponent>(outOfRange).maxHp * 0.5f;
|
||||
f.admin.get<HealthComponent>(outOfRange).hp = initHp;
|
||||
|
||||
f.admin.get<RepairBehaviorComponent>(repairShip).currentTarget = outOfRange;
|
||||
|
||||
f.ai.tickRepairTools(f.admin);
|
||||
|
||||
const entt::entity rc = firstRepairChild(f.admin, repairShip);
|
||||
f.admin.get<RepairToolComponent>(rc).currentTarget = outOfRange;
|
||||
|
||||
f.repair.tick();
|
||||
|
||||
REQUIRE_FALSE(f.admin.get<RepairToolComponent>(rc).currentTarget.has_value());
|
||||
REQUIRE(health(f.admin, outOfRange).hp == Approx(initHp));
|
||||
}
|
||||
|
||||
TEST_CASE("BehaviorSystem: two repair modules both heal preferred target additively",
|
||||
TEST_CASE("RepairSystem: two repair modules both heal the chosen target additively",
|
||||
"[behavior]")
|
||||
{
|
||||
Fixture f;
|
||||
@@ -554,9 +577,8 @@ TEST_CASE("BehaviorSystem: two repair modules both heal preferred target additiv
|
||||
const float initHp = f.admin.get<HealthComponent>(targetA).maxHp * 0.5f;
|
||||
f.admin.get<HealthComponent>(targetA).hp = initHp;
|
||||
|
||||
f.ships.clearMovementIntents();
|
||||
f.ai.tickRepairBehavior(f.admin, f.buildings);
|
||||
f.ai.tickRepairTools(f.admin);
|
||||
f.decide();
|
||||
f.runModules();
|
||||
|
||||
// Both modules should have healed targetA — total increase is 2 * ratePerTick.
|
||||
const float ratePerTick = (5.0f + 1.0f) / static_cast<float>(kTickRateHz);
|
||||
@@ -570,24 +592,27 @@ TEST_CASE("BehaviorSystem: two repair modules both heal preferred target additiv
|
||||
}
|
||||
}
|
||||
|
||||
TEST_CASE("BehaviorSystem: two repair modules both fall back and heal same target when preferred is fully healed",
|
||||
TEST_CASE("RepairSystem: two modules both fall back and heal the same target",
|
||||
"[behavior]")
|
||||
{
|
||||
Fixture f;
|
||||
const ShipLayoutConfig repairLayout = makeTwoModuleLayout("repair_tool");
|
||||
const entt::entity repairShip = f.ships.spawn("repair_ship", 1, QVector2D(0.0f, 0.0f),
|
||||
false, repairLayout);
|
||||
const entt::entity preferred = f.ships.spawn("interceptor", 1, QVector2D(10.0f, 0.0f));
|
||||
const entt::entity targetB = f.ships.spawn("interceptor", 1, QVector2D(20.0f, 0.0f));
|
||||
const entt::entity healed = f.ships.spawn("interceptor", 1, QVector2D(10.0f, 0.0f));
|
||||
const entt::entity targetB = f.ships.spawn("interceptor", 1, QVector2D(20.0f, 0.0f));
|
||||
|
||||
// preferred is at full HP so both modules must fall back
|
||||
f.admin.get<HealthComponent>(preferred).hp = f.admin.get<HealthComponent>(preferred).maxHp;
|
||||
f.admin.get<HealthComponent>(healed).hp = f.admin.get<HealthComponent>(healed).maxHp;
|
||||
const float initHp = f.admin.get<HealthComponent>(targetB).maxHp * 0.5f;
|
||||
f.admin.get<HealthComponent>(targetB).hp = initHp;
|
||||
|
||||
f.admin.get<RepairBehaviorComponent>(repairShip).currentTarget = preferred;
|
||||
// Seed both tools with the (fully-healed) target; they must reacquire targetB.
|
||||
for (const entt::entity child : allRepairChildren(f.admin, repairShip))
|
||||
{
|
||||
f.admin.get<RepairToolComponent>(child).currentTarget = healed;
|
||||
}
|
||||
|
||||
f.ai.tickRepairTools(f.admin);
|
||||
f.repair.tick();
|
||||
|
||||
const float ratePerTick = (5.0f + 1.0f) / static_cast<float>(kTickRateHz);
|
||||
REQUIRE(health(f.admin, targetB).hp == Approx(initHp + 2.0f * ratePerTick));
|
||||
@@ -600,13 +625,12 @@ TEST_CASE("BehaviorSystem: two repair modules both fall back and heal same targe
|
||||
}
|
||||
}
|
||||
|
||||
TEST_CASE("BehaviorSystem: tickRepairTools does not crash when owner lacks RepairBehaviorComponent",
|
||||
TEST_CASE("RepairSystem: does not crash when a tool's owner is not a repair ship",
|
||||
"[behavior]")
|
||||
{
|
||||
Fixture f;
|
||||
|
||||
// Bare child entity: has RepairToolComponent and ModuleOwnerComponent but owner has no
|
||||
// RepairBehaviorComponent.
|
||||
// Bare child entity: RepairToolComponent + ModuleOwnerComponent, owner is a combat ship.
|
||||
const entt::entity ownerShip = f.ships.spawn("interceptor", 1, QVector2D(0.0f, 0.0f));
|
||||
const entt::entity moduleEntity = f.admin.createModuleEntity();
|
||||
RepairToolComponent rt;
|
||||
@@ -616,17 +640,17 @@ TEST_CASE("BehaviorSystem: tickRepairTools does not crash when owner lacks Repai
|
||||
f.admin.addComponent<RepairToolComponent>(moduleEntity, rt);
|
||||
f.admin.addComponent<ModuleOwnerComponent>(moduleEntity, ModuleOwnerComponent{ownerShip});
|
||||
|
||||
// Must not crash.
|
||||
f.ai.tickRepairTools(f.admin);
|
||||
// Must not crash; no damaged friendly in range, so no target is set.
|
||||
f.repair.tick();
|
||||
|
||||
REQUIRE_FALSE(f.admin.get<RepairToolComponent>(moduleEntity).currentTarget.has_value());
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// tickSalvageBehavior
|
||||
// SalvageScrapBehavior / DeliverScrapBehavior
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
TEST_CASE("BehaviorSystem: salvage ship writes intent toward nearest scrap", "[behavior]")
|
||||
TEST_CASE("BehaviorSystem: salvage ship moves toward nearest scrap", "[behavior]")
|
||||
{
|
||||
Fixture f;
|
||||
const ShipLayoutConfig salvageLayout = makeSingleModuleLayout("salvager");
|
||||
@@ -636,10 +660,10 @@ TEST_CASE("BehaviorSystem: salvage ship writes intent toward nearest scrap", "[b
|
||||
const QVector2D scrapPos(100.0f, 0.0f);
|
||||
f.scraps.spawn(scrapPos, 1, 100000);
|
||||
|
||||
f.ships.clearMovementIntents();
|
||||
f.ai.tickSalvageBehavior(f.admin, f.scraps, f.buildings);
|
||||
f.decide();
|
||||
|
||||
REQUIRE(intent(f.admin, ship).priority == 1);
|
||||
REQUIRE(winnerOf(f.admin, ship) == BehaviorKind::SalvageScrap);
|
||||
REQUIRE(intent(f.admin, ship).active);
|
||||
REQUIRE(intent(f.admin, ship).target.x() == Approx(scrapPos.x()));
|
||||
}
|
||||
|
||||
@@ -651,8 +675,7 @@ TEST_CASE("BehaviorSystem: salvage ship collects scrap on arrival", "[behavior]"
|
||||
false, salvageLayout);
|
||||
const entt::entity scrapEntity = f.scraps.spawn(QVector2D(0.0f, 0.0f), 1, 100000);
|
||||
|
||||
f.ships.clearMovementIntents();
|
||||
f.ai.tickSalvageBehavior(f.admin, f.scraps, f.buildings);
|
||||
f.salvager.tick(f.scraps, f.buildings);
|
||||
|
||||
const entt::entity sc = firstSalvageChild(f.admin, ship);
|
||||
REQUIRE(f.admin.isValid(sc));
|
||||
@@ -687,11 +710,12 @@ TEST_CASE("BehaviorSystem: full-cargo salvage ship moves toward SalvageBay", "[b
|
||||
cargo.current = cargo.capacity; // full cargo
|
||||
}
|
||||
|
||||
f.ships.clearMovementIntents();
|
||||
f.ai.tickSalvageBehavior(f.admin, f.scraps, f.buildings);
|
||||
f.decide();
|
||||
|
||||
REQUIRE(winnerOf(f.admin, ship) == BehaviorKind::DeliverScrap);
|
||||
REQUIRE(f.admin.get<DeliverScrapBehavior>(ship).deliveryBay == bayId);
|
||||
const MovementIntentComponent& i = intent(f.admin, ship);
|
||||
REQUIRE(i.priority == 1);
|
||||
REQUIRE(i.active);
|
||||
REQUIRE(i.target.x() < pos(f.admin, ship).value.x());
|
||||
}
|
||||
|
||||
@@ -710,7 +734,7 @@ static int totalSalvageCurrent(EntityAdmin& admin, entt::entity ship)
|
||||
return total;
|
||||
}
|
||||
|
||||
TEST_CASE("BehaviorSystem: salvage module does not collect scrap beyond its collection range",
|
||||
TEST_CASE("SalvagerSystem: module does not collect scrap beyond its collection range",
|
||||
"[behavior]")
|
||||
{
|
||||
// collection_range_m_formula = "50"; scrap at distance 55 must not be collected.
|
||||
@@ -720,13 +744,12 @@ TEST_CASE("BehaviorSystem: salvage module does not collect scrap beyond its coll
|
||||
false, salvageLayout);
|
||||
f.scraps.spawn(QVector2D(55.0f, 0.0f), 1, 100000);
|
||||
|
||||
f.ships.clearMovementIntents();
|
||||
f.ai.tickSalvageBehavior(f.admin, f.scraps, f.buildings);
|
||||
f.salvager.tick(f.scraps, f.buildings);
|
||||
|
||||
REQUIRE(f.admin.get<SalvageCargoComponent>(firstSalvageChild(f.admin, ship)).current == 0);
|
||||
}
|
||||
|
||||
TEST_CASE("BehaviorSystem: salvage module collects scrap within its collection range",
|
||||
TEST_CASE("SalvagerSystem: module collects scrap within its collection range",
|
||||
"[behavior]")
|
||||
{
|
||||
// collection_range_m_formula = "50"; scrap at distance 45 must be collected.
|
||||
@@ -736,8 +759,7 @@ TEST_CASE("BehaviorSystem: salvage module collects scrap within its collection r
|
||||
false, salvageLayout);
|
||||
f.scraps.spawn(QVector2D(45.0f, 0.0f), 1, 100000);
|
||||
|
||||
f.ships.clearMovementIntents();
|
||||
f.ai.tickSalvageBehavior(f.admin, f.scraps, f.buildings);
|
||||
f.salvager.tick(f.scraps, f.buildings);
|
||||
|
||||
REQUIRE(f.admin.get<SalvageCargoComponent>(firstSalvageChild(f.admin, ship)).current == 1);
|
||||
}
|
||||
@@ -746,7 +768,7 @@ TEST_CASE("BehaviorSystem: salvage module collects scrap within its collection r
|
||||
// Collection rate (per-module cooldown)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
TEST_CASE("BehaviorSystem: salvage collection sets cooldown on module", "[behavior]")
|
||||
TEST_CASE("SalvagerSystem: collection sets cooldown on module", "[behavior]")
|
||||
{
|
||||
Fixture f;
|
||||
const ShipLayoutConfig salvageLayout = makeSingleModuleLayout("salvager");
|
||||
@@ -754,8 +776,7 @@ TEST_CASE("BehaviorSystem: salvage collection sets cooldown on module", "[behavi
|
||||
false, salvageLayout);
|
||||
f.scraps.spawn(QVector2D(0.0f, 0.0f), 1, 100000);
|
||||
|
||||
f.ships.clearMovementIntents();
|
||||
f.ai.tickSalvageBehavior(f.admin, f.scraps, f.buildings);
|
||||
f.salvager.tick(f.scraps, f.buildings);
|
||||
|
||||
const SalvageCargoComponent& cargo =
|
||||
f.admin.get<SalvageCargoComponent>(firstSalvageChild(f.admin, ship));
|
||||
@@ -763,7 +784,7 @@ TEST_CASE("BehaviorSystem: salvage collection sets cooldown on module", "[behavi
|
||||
REQUIRE(cargo.cooldownTicksRemaining == cargo.collectionIntervalTicks);
|
||||
}
|
||||
|
||||
TEST_CASE("BehaviorSystem: salvage module on cooldown does not collect scrap", "[behavior]")
|
||||
TEST_CASE("SalvagerSystem: module on cooldown does not collect scrap", "[behavior]")
|
||||
{
|
||||
Fixture f;
|
||||
const ShipLayoutConfig salvageLayout = makeSingleModuleLayout("salvager");
|
||||
@@ -773,13 +794,12 @@ TEST_CASE("BehaviorSystem: salvage module on cooldown does not collect scrap", "
|
||||
|
||||
f.admin.get<SalvageCargoComponent>(firstSalvageChild(f.admin, ship)).cooldownTicksRemaining = 10;
|
||||
|
||||
f.ships.clearMovementIntents();
|
||||
f.ai.tickSalvageBehavior(f.admin, f.scraps, f.buildings);
|
||||
f.salvager.tick(f.scraps, f.buildings);
|
||||
|
||||
REQUIRE(f.admin.get<SalvageCargoComponent>(firstSalvageChild(f.admin, ship)).current == 0);
|
||||
}
|
||||
|
||||
TEST_CASE("BehaviorSystem: salvage module collects again after cooldown expires", "[behavior]")
|
||||
TEST_CASE("SalvagerSystem: module collects again after cooldown expires", "[behavior]")
|
||||
{
|
||||
Fixture f;
|
||||
const ShipLayoutConfig salvageLayout = makeSingleModuleLayout("salvager");
|
||||
@@ -788,8 +808,7 @@ TEST_CASE("BehaviorSystem: salvage module collects again after cooldown expires"
|
||||
const entt::entity sc = firstSalvageChild(f.admin, ship);
|
||||
|
||||
f.scraps.spawn(QVector2D(0.0f, 0.0f), 1, 100000);
|
||||
f.ships.clearMovementIntents();
|
||||
f.ai.tickSalvageBehavior(f.admin, f.scraps, f.buildings);
|
||||
f.salvager.tick(f.scraps, f.buildings);
|
||||
REQUIRE(f.admin.get<SalvageCargoComponent>(sc).current == 1);
|
||||
|
||||
// Shorten cooldown to 1 tick and place a second scrap.
|
||||
@@ -797,8 +816,7 @@ TEST_CASE("BehaviorSystem: salvage module collects again after cooldown expires"
|
||||
f.scraps.spawn(QVector2D(0.0f, 0.0f), 1, 100000);
|
||||
|
||||
// Next tick: cooldown decrements to 0, module collects the second scrap.
|
||||
f.ships.clearMovementIntents();
|
||||
f.ai.tickSalvageBehavior(f.admin, f.scraps, f.buildings);
|
||||
f.salvager.tick(f.scraps, f.buildings);
|
||||
|
||||
REQUIRE(f.admin.get<SalvageCargoComponent>(sc).current == 2);
|
||||
}
|
||||
@@ -807,7 +825,7 @@ TEST_CASE("BehaviorSystem: salvage module collects again after cooldown expires"
|
||||
// Multiple salvage modules
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
TEST_CASE("BehaviorSystem: two salvage modules collect independently in same tick", "[behavior]")
|
||||
TEST_CASE("SalvagerSystem: two salvage modules collect independently in same tick", "[behavior]")
|
||||
{
|
||||
Fixture f;
|
||||
const ShipLayoutConfig salvageLayout = makeTwoModuleLayout("salvager");
|
||||
@@ -817,13 +835,12 @@ TEST_CASE("BehaviorSystem: two salvage modules collect independently in same tic
|
||||
f.scraps.spawn(QVector2D(0.0f, 0.0f), 1, 100000);
|
||||
f.scraps.spawn(QVector2D(0.0f, 0.0f), 1, 100000);
|
||||
|
||||
f.ships.clearMovementIntents();
|
||||
f.ai.tickSalvageBehavior(f.admin, f.scraps, f.buildings);
|
||||
f.salvager.tick(f.scraps, f.buildings);
|
||||
|
||||
REQUIRE(totalSalvageCurrent(f.admin, ship) == 2);
|
||||
}
|
||||
|
||||
TEST_CASE("BehaviorSystem: second salvage module does not collect when first module is on cooldown",
|
||||
TEST_CASE("SalvagerSystem: second salvage module does not collect when first is on cooldown",
|
||||
"[behavior]")
|
||||
{
|
||||
// One module on cooldown, one ready: only the ready module collects.
|
||||
@@ -847,8 +864,7 @@ TEST_CASE("BehaviorSystem: second salvage module does not collect when first mod
|
||||
f.scraps.spawn(QVector2D(0.0f, 0.0f), 1, 100000);
|
||||
f.scraps.spawn(QVector2D(0.0f, 0.0f), 1, 100000);
|
||||
|
||||
f.ships.clearMovementIntents();
|
||||
f.ai.tickSalvageBehavior(f.admin, f.scraps, f.buildings);
|
||||
f.salvager.tick(f.scraps, f.buildings);
|
||||
|
||||
// Only one module was ready, so only one scrap is collected.
|
||||
REQUIRE(totalSalvageCurrent(f.admin, ship) == 1);
|
||||
@@ -866,7 +882,7 @@ TEST_CASE("SensorRange: sensorRange is populated from config formula at spawn",
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sensor range — tickThreatResponseBehavior
|
||||
// Sensor range — AttackBehavior
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
TEST_CASE("SensorRange: player combat ship acquires enemy just inside sensor range", "[sensor]")
|
||||
@@ -876,10 +892,9 @@ TEST_CASE("SensorRange: player combat ship acquires enemy just inside sensor ran
|
||||
const entt::entity enemy = f.ships.spawn("interceptor", 1, QVector2D(190.0f, 0.0f),
|
||||
/*isEnemy=*/true);
|
||||
|
||||
f.ships.clearMovementIntents();
|
||||
f.ai.tickThreatResponseBehavior(f.admin, f.buildings);
|
||||
f.decide();
|
||||
|
||||
REQUIRE(f.admin.get<ThreatResponseBehaviorComponent>(player).currentTarget == enemy);
|
||||
REQUIRE(f.admin.get<AttackBehavior>(player).currentTarget == enemy);
|
||||
}
|
||||
|
||||
TEST_CASE("SensorRange: player combat ship ignores enemy just outside sensor range", "[sensor]")
|
||||
@@ -888,10 +903,9 @@ TEST_CASE("SensorRange: player combat ship ignores enemy just outside sensor ran
|
||||
const entt::entity player = 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.ai.tickThreatResponseBehavior(f.admin, f.buildings);
|
||||
f.decide();
|
||||
|
||||
REQUIRE_FALSE(f.admin.get<ThreatResponseBehaviorComponent>(player).currentTarget.has_value());
|
||||
REQUIRE_FALSE(f.admin.get<AttackBehavior>(player).currentTarget.has_value());
|
||||
}
|
||||
|
||||
TEST_CASE("SensorRange: enemy ship ignores player just outside sensor range", "[sensor]")
|
||||
@@ -901,29 +915,29 @@ TEST_CASE("SensorRange: enemy ship ignores player just outside sensor range", "[
|
||||
const entt::entity enemy = f.ships.spawn("interceptor", 1, QVector2D(210.0f, 0.0f),
|
||||
/*isEnemy=*/true);
|
||||
|
||||
f.ships.clearMovementIntents();
|
||||
f.ai.tickThreatResponseBehavior(f.admin, f.buildings);
|
||||
f.decide();
|
||||
|
||||
REQUIRE_FALSE(f.admin.get<ThreatResponseBehaviorComponent>(enemy).currentTarget.has_value());
|
||||
REQUIRE_FALSE(f.admin.get<AttackBehavior>(enemy).currentTarget.has_value());
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sensor range — tickRepairBehavior
|
||||
// Sensor range — RetreatBehavior (unarmed ships flee threats)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
TEST_CASE("SensorRange: repair ship retreats from enemy within sensor range", "[sensor]")
|
||||
{
|
||||
Fixture f;
|
||||
const QVector2D rallyPoint(-100.0f, 0.0f);
|
||||
f.ships.setRallyPoint(rallyPoint);
|
||||
const ShipLayoutConfig repairLayout = makeSingleModuleLayout("repair_tool");
|
||||
const entt::entity repairShip = f.ships.spawn("repair_ship", 1, QVector2D(0.0f, 0.0f),
|
||||
false, repairLayout);
|
||||
f.ships.spawn("interceptor", 1, QVector2D(200.0f, 0.0f), /*isEnemy=*/true);
|
||||
|
||||
f.ships.clearMovementIntents();
|
||||
f.ai.tickRepairBehavior(f.admin, f.buildings);
|
||||
f.decide();
|
||||
|
||||
REQUIRE(intent(f.admin, repairShip).priority == 2);
|
||||
REQUIRE(intent(f.admin, repairShip).target.x() < 0.0f);
|
||||
REQUIRE(winnerOf(f.admin, repairShip) == BehaviorKind::Retreat);
|
||||
REQUIRE(intent(f.admin, repairShip).target.x() == Approx(rallyPoint.x()));
|
||||
}
|
||||
|
||||
TEST_CASE("SensorRange: repair ship does not retreat from enemy beyond sensor range", "[sensor]")
|
||||
@@ -934,9 +948,9 @@ TEST_CASE("SensorRange: repair ship does not retreat from enemy beyond sensor ra
|
||||
false, repairLayout);
|
||||
f.ships.spawn("interceptor", 1, QVector2D(300.0f, 0.0f), /*isEnemy=*/true);
|
||||
|
||||
f.ships.clearMovementIntents();
|
||||
f.ai.tickRepairBehavior(f.admin, f.buildings);
|
||||
f.decide();
|
||||
|
||||
REQUIRE(winnerOf(f.admin, repairShip) != BehaviorKind::Retreat);
|
||||
REQUIRE(intent(f.admin, repairShip).target.x() > pos(f.admin, repairShip).value.x());
|
||||
}
|
||||
|
||||
@@ -949,14 +963,13 @@ TEST_CASE("SensorRange: repair ship does not acquire damaged ally beyond sensor
|
||||
const entt::entity friendly = f.ships.spawn("interceptor", 1, QVector2D(300.0f, 0.0f));
|
||||
f.admin.get<HealthComponent>(friendly).hp = f.admin.get<HealthComponent>(friendly).maxHp * 0.5f;
|
||||
|
||||
f.ships.clearMovementIntents();
|
||||
f.ai.tickRepairBehavior(f.admin, f.buildings);
|
||||
f.decide();
|
||||
|
||||
REQUIRE_FALSE(f.admin.get<RepairBehaviorComponent>(repairShip).currentTarget.has_value());
|
||||
REQUIRE_FALSE(f.admin.get<RepairBehavior>(repairShip).currentTarget.has_value());
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sensor range — tickSalvageBehavior
|
||||
// Sensor range — SalvageScrapBehavior
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
TEST_CASE("SensorRange: salvage ship ignores scrap beyond sensor range", "[sensor]")
|
||||
@@ -967,9 +980,8 @@ TEST_CASE("SensorRange: salvage ship ignores scrap beyond sensor range", "[senso
|
||||
false, salvageLayout);
|
||||
f.scraps.spawn(QVector2D(300.0f, 0.0f), 1, 100000);
|
||||
|
||||
f.ships.clearMovementIntents();
|
||||
f.ai.tickSalvageBehavior(f.admin, f.scraps, f.buildings);
|
||||
f.decide();
|
||||
|
||||
REQUIRE_FALSE(f.admin.get<SalvageBehaviorComponent>(ship).scrapTarget.has_value());
|
||||
REQUIRE_FALSE(f.admin.get<SalvageScrapBehavior>(ship).scrapTarget.has_value());
|
||||
REQUIRE(intent(f.admin, ship).target.x() > pos(f.admin, ship).value.x());
|
||||
}
|
||||
|
||||
@@ -17,9 +17,9 @@
|
||||
#include "ScrapSystem.h"
|
||||
#include "ShipSystem.h"
|
||||
#include "Simulation.h"
|
||||
#include "AttackBehavior.h"
|
||||
#include "StationBodyComponent.h"
|
||||
#include "Tick.h"
|
||||
#include "ThreatResponseBehaviorComponent.h"
|
||||
#include "WeaponComponent.h"
|
||||
|
||||
static GameConfig loadConfig()
|
||||
@@ -80,17 +80,18 @@ struct CombatFixture
|
||||
|
||||
void wireEnemyTarget(entt::entity enemy, entt::entity playerTarget)
|
||||
{
|
||||
// Set target on weapon child entity (CombatSystem syncs from ThreatResponse each tick,
|
||||
// but also setting directly ensures the first tick fires without waiting for sync).
|
||||
// Set the target directly on the weapon child entity. CombatSystem now
|
||||
// fires at whatever target a weapon already has (AttackExecutor would set
|
||||
// it in a full tick); setting it here drives CombatSystem in isolation.
|
||||
const entt::entity wc = findWeaponChild(admin, enemy);
|
||||
if (wc != entt::null)
|
||||
{
|
||||
admin.get<WeaponComponent>(wc).currentTarget = playerTarget;
|
||||
admin.get<WeaponComponent>(wc).cooldownTicks = 0.0f;
|
||||
}
|
||||
if (admin.hasAll<ThreatResponseBehaviorComponent>(enemy))
|
||||
if (admin.hasAll<AttackBehavior>(enemy))
|
||||
{
|
||||
admin.get<ThreatResponseBehaviorComponent>(enemy).currentTarget = playerTarget;
|
||||
admin.get<AttackBehavior>(enemy).currentTarget = playerTarget;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -6,22 +6,27 @@
|
||||
#include <QPoint>
|
||||
#include <QVector2D>
|
||||
|
||||
#include "AdvanceBehavior.h"
|
||||
#include "AttackBehavior.h"
|
||||
#include "BuildingId.h"
|
||||
#include "ConfigLoader.h"
|
||||
#include "DeliverScrapBehavior.h"
|
||||
#include "DynamicBodyComponent.h"
|
||||
#include "EntityAdmin.h"
|
||||
#include "HealthComponent.h"
|
||||
#include "ModuleOwnerComponent.h"
|
||||
#include "RepairBehaviorComponent.h"
|
||||
#include "RallyBehavior.h"
|
||||
#include "RepairBehavior.h"
|
||||
#include "RepairToolComponent.h"
|
||||
#include "RetreatBehavior.h"
|
||||
#include "Rotation.h"
|
||||
#include "SalvageBehaviorComponent.h"
|
||||
#include "SalvageCargoComponent.h"
|
||||
#include "SalvageScrapBehavior.h"
|
||||
#include "SelectedBehaviorComponent.h"
|
||||
#include "SensorRangeComponent.h"
|
||||
#include "ShipLayout.h"
|
||||
#include "ShipSystem.h"
|
||||
#include "Tick.h"
|
||||
#include "ThreatResponseBehaviorComponent.h"
|
||||
#include "WeaponComponent.h"
|
||||
|
||||
static GameConfig loadConfig()
|
||||
@@ -81,7 +86,7 @@ static ShipLayoutConfig makeSingleModuleLayout(const std::string& moduleId)
|
||||
// Combat ship (interceptor has default_modules = [laser_cannon])
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
TEST_CASE("ShipSystem: interceptor spawn has weapon child and threatResponse, no cargo or repair",
|
||||
TEST_CASE("ShipSystem: interceptor spawn has weapon child and attack behavior, no cargo or repair",
|
||||
"[ship]")
|
||||
{
|
||||
EntityAdmin admin;
|
||||
@@ -92,11 +97,47 @@ TEST_CASE("ShipSystem: interceptor spawn has weapon child and threatResponse, no
|
||||
|
||||
REQUIRE(admin.isValid(e));
|
||||
REQUIRE(admin.isValid(firstWeaponChild(admin, e)));
|
||||
REQUIRE(admin.hasAll<ThreatResponseBehaviorComponent>(e));
|
||||
REQUIRE(admin.hasAll<AttackBehavior>(e));
|
||||
// Every ship gets the baseline behaviors; a player combat ship also rallies
|
||||
// and can retreat.
|
||||
REQUIRE(admin.hasAll<AdvanceBehavior>(e));
|
||||
REQUIRE(admin.hasAll<SelectedBehaviorComponent>(e));
|
||||
REQUIRE(admin.hasAll<RallyBehavior>(e));
|
||||
REQUIRE(admin.hasAll<RetreatBehavior>(e));
|
||||
REQUIRE_FALSE(admin.isValid(firstSalvageChild(admin, e)));
|
||||
REQUIRE_FALSE(admin.isValid(firstRepairChild(admin, e)));
|
||||
REQUIRE_FALSE(admin.hasAll<RepairBehaviorComponent>(e));
|
||||
REQUIRE_FALSE(admin.hasAll<SalvageBehaviorComponent>(e));
|
||||
REQUIRE_FALSE(admin.hasAll<RepairBehavior>(e));
|
||||
REQUIRE_FALSE(admin.hasAll<SalvageScrapBehavior>(e));
|
||||
REQUIRE_FALSE(admin.hasAll<DeliverScrapBehavior>(e));
|
||||
}
|
||||
|
||||
TEST_CASE("ShipSystem: enemy combat ship has no rally or retreat behavior", "[ship]")
|
||||
{
|
||||
EntityAdmin admin;
|
||||
const GameConfig cfg = loadConfig();
|
||||
ShipSystem ss(cfg, admin);
|
||||
|
||||
const entt::entity e = ss.spawn("interceptor", 1, QVector2D(0.0f, 0.0f), /*isEnemy=*/true);
|
||||
|
||||
REQUIRE(admin.hasAll<AttackBehavior>(e));
|
||||
REQUIRE(admin.hasAll<AdvanceBehavior>(e));
|
||||
REQUIRE_FALSE(admin.hasAll<RallyBehavior>(e));
|
||||
REQUIRE_FALSE(admin.hasAll<RetreatBehavior>(e));
|
||||
}
|
||||
|
||||
TEST_CASE("ShipSystem: setRetreatEnabled(false) suppresses player retreat behavior", "[ship]")
|
||||
{
|
||||
EntityAdmin admin;
|
||||
const GameConfig cfg = loadConfig();
|
||||
ShipSystem ss(cfg, admin);
|
||||
ss.setRetreatEnabled(false);
|
||||
|
||||
const entt::entity e = ss.spawn("interceptor", 1, QVector2D(0.0f, 0.0f));
|
||||
|
||||
// Other player behaviors are unaffected; only retreat is suppressed.
|
||||
REQUIRE(admin.hasAll<AttackBehavior>(e));
|
||||
REQUIRE(admin.hasAll<RallyBehavior>(e));
|
||||
REQUIRE_FALSE(admin.hasAll<RetreatBehavior>(e));
|
||||
}
|
||||
|
||||
TEST_CASE("ShipSystem: interceptor level 1 stats match config formulas", "[ship]")
|
||||
@@ -161,7 +202,8 @@ TEST_CASE("ShipSystem: salvage_ship spawn with salvage module has cargo child an
|
||||
const entt::entity e = ss.spawn("salvage_ship", 1, QVector2D(0.0f, 0.0f), false, layout);
|
||||
|
||||
REQUIRE(admin.isValid(firstSalvageChild(admin, e)));
|
||||
REQUIRE(admin.hasAll<SalvageBehaviorComponent>(e));
|
||||
REQUIRE(admin.hasAll<SalvageScrapBehavior>(e));
|
||||
REQUIRE(admin.hasAll<DeliverScrapBehavior>(e));
|
||||
REQUIRE_FALSE(admin.isValid(firstWeaponChild(admin, e)));
|
||||
REQUIRE_FALSE(admin.isValid(firstRepairChild(admin, e)));
|
||||
}
|
||||
@@ -180,9 +222,9 @@ TEST_CASE("ShipSystem: salvage_ship cargo capacity matches config", "[ship]")
|
||||
REQUIRE(admin.isValid(sc));
|
||||
REQUIRE(admin.get<SalvageCargoComponent>(sc).capacity == 10);
|
||||
REQUIRE(admin.get<SalvageCargoComponent>(sc).current == 0);
|
||||
REQUIRE(admin.get<SalvageBehaviorComponent>(e).deliveryBay == kInvalidBuildingId);
|
||||
REQUIRE_FALSE(admin.get<SalvageBehaviorComponent>(e).scrapTarget.has_value());
|
||||
REQUIRE(admin.get<SalvageBehaviorComponent>(e).maxCollectionRange_tiles == Approx(50.0f));
|
||||
REQUIRE(admin.get<DeliverScrapBehavior>(e).deliveryBay == kInvalidBuildingId);
|
||||
REQUIRE_FALSE(admin.get<SalvageScrapBehavior>(e).scrapTarget.has_value());
|
||||
REQUIRE(admin.get<SalvageScrapBehavior>(e).maxCollectionRange_tiles == Approx(50.0f));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -200,7 +242,7 @@ TEST_CASE("ShipSystem: repair_ship spawn with repair module has repair child and
|
||||
const entt::entity e = ss.spawn("repair_ship", 1, QVector2D(0.0f, 0.0f), false, layout);
|
||||
|
||||
REQUIRE(admin.isValid(firstRepairChild(admin, e)));
|
||||
REQUIRE(admin.hasAll<RepairBehaviorComponent>(e));
|
||||
REQUIRE(admin.hasAll<RepairBehavior>(e));
|
||||
REQUIRE_FALSE(admin.isValid(firstWeaponChild(admin, e)));
|
||||
REQUIRE_FALSE(admin.isValid(firstSalvageChild(admin, e)));
|
||||
}
|
||||
@@ -221,7 +263,7 @@ TEST_CASE("ShipSystem: repair_ship level 1 repair stats match config formulas",
|
||||
REQUIRE(admin.get<RepairToolComponent>(rc).ratePerTick == Approx(expectedRate));
|
||||
// repair_range_m_formula = "800" m → 800/10 = 80 tiles
|
||||
REQUIRE(admin.get<RepairToolComponent>(rc).range_tiles == Approx(80.0f));
|
||||
REQUIRE(admin.get<RepairBehaviorComponent>(e).maxRepairRange_tiles == Approx(80.0f));
|
||||
REQUIRE(admin.get<RepairBehavior>(e).maxRepairRange_tiles == Approx(80.0f));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user