make ships claim targets
This commit is contained in:
@@ -56,7 +56,7 @@ ArenaSimulation::ArenaSimulation(const GameConfig& gameConfig,
|
||||
// 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_aiSystem = std::make_unique<AiSystem>(m_gameConfig);
|
||||
m_movementIntentSystem = std::make_unique<MovementIntentSystem>();
|
||||
m_dynamicBodySystem = std::make_unique<DynamicBodySystem>();
|
||||
m_combatSystem = std::make_unique<CombatSystem>(m_gameConfig);
|
||||
|
||||
@@ -297,6 +297,10 @@ WorldConfig ConfigLoader::loadWorld(const std::string& path)
|
||||
throw makeError(file, "waves", "gap_min_seconds > gap_max_seconds");
|
||||
}
|
||||
|
||||
cfg.targeting.targetScoreFormula = requireFormula(tbl["targeting"]["target_score_formula"], file, "targeting.target_score_formula");
|
||||
cfg.targeting.overclaimPenaltyFormula = requireFormula(tbl["targeting"]["overclaim_penalty_formula"], file, "targeting.overclaim_penalty_formula");
|
||||
cfg.targeting.hysteresis = requireDouble(tbl["targeting"]["target_hysteresis"], file, "targeting.target_hysteresis");
|
||||
|
||||
return cfg;
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,14 @@
|
||||
|
||||
#include "tinyexpr.h"
|
||||
|
||||
namespace
|
||||
{
|
||||
// tinyexpr has no built-in min/max; expose them so config formulas can
|
||||
// clamp (e.g. a floored overclaim penalty "max(0.5, 1 - 0.1*x)").
|
||||
double formulaMin(double a, double b) { return a < b ? a : b; }
|
||||
double formulaMax(double a, double b) { return a > b ? a : b; }
|
||||
}
|
||||
|
||||
Formula::Formula(Formula&& other) noexcept
|
||||
: m_source(std::move(other.m_source))
|
||||
, m_x(std::move(other.m_x))
|
||||
@@ -37,11 +45,14 @@ Formula Formula::compile(const std::string& source)
|
||||
result.m_x = std::make_unique<double>(0.0);
|
||||
|
||||
const te_variable variables[] = {
|
||||
{ "x", result.m_x.get(), 0, nullptr },
|
||||
{ "x", result.m_x.get(), TE_VARIABLE, nullptr },
|
||||
{ "min", reinterpret_cast<const void*>(&formulaMin), TE_FUNCTION2 | TE_FLAG_PURE, nullptr },
|
||||
{ "max", reinterpret_cast<const void*>(&formulaMax), TE_FUNCTION2 | TE_FLAG_PURE, nullptr },
|
||||
};
|
||||
|
||||
int errorPos = 0;
|
||||
result.m_expr = te_compile(result.m_source.c_str(), variables, 1, &errorPos);
|
||||
const int variableCount = static_cast<int>(sizeof(variables) / sizeof(variables[0]));
|
||||
result.m_expr = te_compile(result.m_source.c_str(), variables, variableCount, &errorPos);
|
||||
|
||||
if (result.m_expr == nullptr)
|
||||
{
|
||||
@@ -66,3 +77,4 @@ double Formula::evaluate(double x) const
|
||||
*m_x = x;
|
||||
return te_eval(m_expr);
|
||||
}
|
||||
|
||||
|
||||
@@ -39,6 +39,14 @@ struct WorldWaves
|
||||
double bossQuietAfterSeconds; // suppress normal waves this long after boss (REQ-WAV-QUIET)
|
||||
};
|
||||
|
||||
// Ship target selection (claim-aware scoring).
|
||||
struct WorldTargeting
|
||||
{
|
||||
Formula targetScoreFormula; // x = distance / max weapon range; higher = better
|
||||
Formula overclaimPenaltyFormula; // x = competing claim count; factor in [0,1]
|
||||
double hysteresis; // fractional margin a challenger must beat the current target by
|
||||
};
|
||||
|
||||
struct WorldConfig
|
||||
{
|
||||
int heightTiles; // REQ-GW-HEIGHT
|
||||
@@ -56,4 +64,5 @@ struct WorldConfig
|
||||
WorldExpansion expansion;
|
||||
WorldPush push;
|
||||
WorldWaves waves;
|
||||
WorldTargeting targeting;
|
||||
};
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
#include <limits>
|
||||
|
||||
#include "GameConfig.h"
|
||||
|
||||
#include "AdvanceBehavior.h"
|
||||
#include "AttackBehavior.h"
|
||||
#include "BehaviorKind.h"
|
||||
@@ -34,6 +36,11 @@ namespace
|
||||
}
|
||||
}
|
||||
|
||||
AiSystem::AiSystem(const GameConfig& config)
|
||||
: m_attackEvaluator(config.world.targeting)
|
||||
{
|
||||
}
|
||||
|
||||
void AiSystem::tick(EntityAdmin& admin, const BuildingSystem& buildings,
|
||||
const ScrapSystem& scraps)
|
||||
{
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
class BuildingSystem;
|
||||
class EntityAdmin;
|
||||
class ScrapSystem;
|
||||
struct GameConfig;
|
||||
|
||||
// Orchestrates ship-behavior decision-making in three batched phases:
|
||||
// 1. evaluators score each behavior and set its target data,
|
||||
@@ -29,6 +30,8 @@ class ScrapSystem;
|
||||
class AiSystem
|
||||
{
|
||||
public:
|
||||
explicit AiSystem(const GameConfig& config);
|
||||
|
||||
void tick(EntityAdmin& admin, const BuildingSystem& buildings, const ScrapSystem& scraps);
|
||||
|
||||
private:
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
#include "AttackEvaluator.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <unordered_map>
|
||||
#include <vector>
|
||||
|
||||
#include <QVector2D>
|
||||
@@ -10,57 +12,131 @@
|
||||
#include "EntityAdmin.h"
|
||||
#include "FactionComponent.h"
|
||||
#include "HealthComponent.h"
|
||||
#include "ModuleOwnerComponent.h"
|
||||
#include "PositionComponent.h"
|
||||
#include "SensorRangeComponent.h"
|
||||
#include "tracing.h"
|
||||
#include "WeaponComponent.h"
|
||||
#include "WorldConfig.h"
|
||||
|
||||
AttackEvaluator::AttackEvaluator(const WorldTargeting& targeting)
|
||||
: m_targeting(&targeting)
|
||||
{
|
||||
}
|
||||
|
||||
void AttackEvaluator::evaluate(EntityAdmin& admin)
|
||||
{
|
||||
TRACE();
|
||||
const std::vector<CombatantInfo> combatants = buildCombatants(admin);
|
||||
|
||||
// Pass A: the maximum weapon range per ship, used to normalise target
|
||||
// distance. Ships without a weapon fall back to their sensor range below.
|
||||
std::unordered_map<entt::entity, float> maxWeaponRange_tiles;
|
||||
admin.forEach<WeaponComponent, ModuleOwnerComponent>(
|
||||
[&maxWeaponRange_tiles](entt::entity /*we*/, const WeaponComponent& weapon,
|
||||
const ModuleOwnerComponent& owner)
|
||||
{
|
||||
float& best = maxWeaponRange_tiles[owner.owner];
|
||||
best = std::max(best, weapon.range_tiles);
|
||||
});
|
||||
|
||||
// Pass B: claim counts, taken from every ship's current target before any
|
||||
// target is reassigned this tick. Each ship reads the previous tick's claim
|
||||
// state and excludes its own contribution when scoring its current target.
|
||||
std::unordered_map<entt::entity, int> claimsByTarget;
|
||||
admin.forEach<AttackBehavior>(
|
||||
[&claimsByTarget, &admin](entt::entity /*e*/, const AttackBehavior& attack)
|
||||
{
|
||||
if (attack.currentTarget && admin.isValid(*attack.currentTarget))
|
||||
{
|
||||
++claimsByTarget[*attack.currentTarget];
|
||||
}
|
||||
});
|
||||
|
||||
// Pass C: per-ship target selection.
|
||||
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;
|
||||
const float sensorRange_tiles = sensor.value_tiles;
|
||||
|
||||
// Validate current target: still valid, still in range.
|
||||
bool targetValid = false;
|
||||
// Distance normaliser: max weapon range, or sensor range if unarmed.
|
||||
float weaponRange_tiles = sensorRange_tiles;
|
||||
const auto weaponRangeIt = maxWeaponRange_tiles.find(e);
|
||||
if (weaponRangeIt != maxWeaponRange_tiles.end() && weaponRangeIt->second > 0.0f)
|
||||
{
|
||||
weaponRange_tiles = weaponRangeIt->second;
|
||||
}
|
||||
|
||||
// Scores a single candidate: base desirability from distance, reduced
|
||||
// by the overclaim penalty. selfClaimed subtracts this ship's own claim
|
||||
// so it does not penalise the target it already holds.
|
||||
const auto scoreOf =
|
||||
[&](const QVector2D& candidatePos, entt::entity candidate) -> float
|
||||
{
|
||||
const float dist = (candidatePos - pos.value).length();
|
||||
const float x = dist / weaponRange_tiles;
|
||||
float base = static_cast<float>(m_targeting->targetScoreFormula.evaluate(x));
|
||||
base = std::max(base, 0.0f);
|
||||
|
||||
int claims = 0;
|
||||
const auto claimIt = claimsByTarget.find(candidate);
|
||||
if (claimIt != claimsByTarget.end()) { claims = claimIt->second; }
|
||||
if (attack.currentTarget && candidate == *attack.currentTarget) { --claims; }
|
||||
|
||||
float penalty = static_cast<float>(
|
||||
m_targeting->overclaimPenaltyFormula.evaluate(claims));
|
||||
penalty = std::clamp(penalty, 0.0f, 1.0f);
|
||||
|
||||
return base * penalty;
|
||||
};
|
||||
|
||||
// Find the best candidate among in-range enemies.
|
||||
std::optional<entt::entity> bestTarget;
|
||||
float bestScore = 0.0f;
|
||||
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 > sensorRange_tiles) { continue; }
|
||||
|
||||
const float score = scoreOf(c.position, c.entity);
|
||||
if (!bestTarget || score > bestScore)
|
||||
{
|
||||
bestScore = score;
|
||||
bestTarget = c.entity;
|
||||
}
|
||||
}
|
||||
|
||||
// Hysteresis: keep the current target if it is still valid and in
|
||||
// range, unless a challenger beats its score by more than the margin.
|
||||
bool keptCurrent = 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)
|
||||
const QVector2D targetPos = admin.get<PositionComponent>(t).value;
|
||||
const float dist = (targetPos - pos.value).length();
|
||||
if (dist <= sensorRange_tiles)
|
||||
{
|
||||
bestDist = dist;
|
||||
attack.currentTarget = c.entity;
|
||||
const float currentScore = scoreOf(targetPos, t);
|
||||
const float margin = 1.0f + static_cast<float>(m_targeting->hysteresis);
|
||||
if (!bestTarget || bestScore <= currentScore * margin)
|
||||
{
|
||||
keptCurrent = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!keptCurrent) { attack.currentTarget = bestTarget; }
|
||||
|
||||
const bool healthy =
|
||||
(health.maxHp > 0.0f)
|
||||
&& (health.hp / health.maxHp >= BehaviorScores::kLowHpFraction);
|
||||
|
||||
@@ -1,11 +1,22 @@
|
||||
#pragma once
|
||||
|
||||
class EntityAdmin;
|
||||
struct WorldTargeting;
|
||||
|
||||
// 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.
|
||||
//
|
||||
// Target choice is claim-aware: each tick the desirability of every candidate is
|
||||
// scored from a configurable distance formula and reduced by a soft overclaim
|
||||
// penalty that scales with how many other ships already target it, spreading
|
||||
// ships across enemies instead of dogpiling the nearest one.
|
||||
class AttackEvaluator
|
||||
{
|
||||
public:
|
||||
explicit AttackEvaluator(const WorldTargeting& targeting);
|
||||
|
||||
void evaluate(EntityAdmin& admin);
|
||||
|
||||
private:
|
||||
const WorldTargeting* m_targeting;
|
||||
};
|
||||
|
||||
@@ -66,7 +66,7 @@ Simulation::Simulation(GameConfig config, unsigned int seed)
|
||||
[this](const std::string& itemId) -> bool { return isItemUnlocked(itemId); },
|
||||
m_rng);
|
||||
m_shipSystem = std::make_unique<ShipSystem>(m_config, m_admin);
|
||||
m_aiSystem = std::make_unique<AiSystem>();
|
||||
m_aiSystem = std::make_unique<AiSystem>(m_config);
|
||||
m_movementIntentSystem = std::make_unique<MovementIntentSystem>();
|
||||
m_dynamicBodySystem = std::make_unique<DynamicBodySystem>();
|
||||
m_scrapSystem = std::make_unique<ScrapSystem>(m_admin);
|
||||
@@ -169,7 +169,7 @@ void Simulation::reset(unsigned int seed)
|
||||
[this](const std::string& itemId) -> bool { return isItemUnlocked(itemId); },
|
||||
m_rng);
|
||||
m_shipSystem = std::make_unique<ShipSystem>(m_config, m_admin);
|
||||
m_aiSystem = std::make_unique<AiSystem>();
|
||||
m_aiSystem = std::make_unique<AiSystem>(m_config);
|
||||
m_movementIntentSystem = std::make_unique<MovementIntentSystem>();
|
||||
m_dynamicBodySystem = std::make_unique<DynamicBodySystem>();
|
||||
m_scrapSystem = std::make_unique<ScrapSystem>(m_admin);
|
||||
|
||||
@@ -83,6 +83,7 @@ struct Fixture
|
||||
[](const std::string&) -> bool { return true; },
|
||||
rng)
|
||||
, ships(cfg, admin)
|
||||
, ai(cfg)
|
||||
, salvager(admin)
|
||||
, repair(admin)
|
||||
, scraps(admin)
|
||||
@@ -351,6 +352,100 @@ TEST_CASE("BehaviorSystem: player combat ship ignores enemy beyond engagement ra
|
||||
REQUIRE_FALSE(f.admin.get<AttackBehavior>(player).currentTarget.has_value());
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// AttackBehavior — overclaim penalty & hysteresis
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Absent claims, the nearer enemy wins; once another ship claims that enemy, the
|
||||
// overclaim penalty steers the deciding ship to the unclaimed, equidistant one.
|
||||
TEST_CASE("BehaviorSystem: overclaim penalty steers a ship off a claimed target",
|
||||
"[behavior]")
|
||||
{
|
||||
SECTION("no claim: the nearer enemy is chosen")
|
||||
{
|
||||
Fixture f;
|
||||
const entt::entity player = f.ships.spawn("interceptor", 1, QVector2D(0.0f, 0.0f));
|
||||
const entt::entity nearEnemy = f.ships.spawn("interceptor", 1, QVector2D(8.0f, 0.0f),
|
||||
/*isEnemy=*/true);
|
||||
f.ships.spawn("interceptor", 1, QVector2D(0.0f, 12.0f), /*isEnemy=*/true);
|
||||
|
||||
f.decide();
|
||||
|
||||
REQUIRE(f.admin.get<AttackBehavior>(player).currentTarget == nearEnemy);
|
||||
}
|
||||
|
||||
SECTION("enemyA already claimed: penalty redirects to the unclaimed enemyB")
|
||||
{
|
||||
const float d = 10.0f;
|
||||
Fixture f;
|
||||
const entt::entity player = f.ships.spawn("interceptor", 1, QVector2D(0.0f, 0.0f));
|
||||
const entt::entity enemyA = f.ships.spawn("interceptor", 1, QVector2D(d, 0.0f),
|
||||
/*isEnemy=*/true);
|
||||
const entt::entity enemyB = f.ships.spawn("interceptor", 1, QVector2D(0.0f, d),
|
||||
/*isEnemy=*/true);
|
||||
|
||||
// A second player ship already commits to enemyA, registering a claim, so
|
||||
// the penalised score of enemyA falls below the unclaimed equidistant enemyB.
|
||||
const entt::entity claimant = f.ships.spawn("interceptor", 1, QVector2D(d, 1.0f));
|
||||
f.admin.get<AttackBehavior>(claimant).currentTarget = enemyA;
|
||||
|
||||
f.decide();
|
||||
|
||||
REQUIRE(f.admin.get<AttackBehavior>(player).currentTarget == enemyB);
|
||||
}
|
||||
}
|
||||
|
||||
// Hysteresis keeps a ship on its committed target when a fresh candidate is only
|
||||
// marginally better — and self-exclusion means the ship's own claim never counts
|
||||
// against the target it already holds.
|
||||
TEST_CASE("BehaviorSystem: hysteresis keeps a ship on its own claimed target",
|
||||
"[behavior]")
|
||||
{
|
||||
const float d = 10.0f;
|
||||
Fixture f;
|
||||
const entt::entity player = f.ships.spawn("interceptor", 1, QVector2D(0.0f, 0.0f));
|
||||
const entt::entity enemyA = f.ships.spawn("interceptor", 1, QVector2D(d, 0.0f),
|
||||
/*isEnemy=*/true);
|
||||
f.ships.spawn("interceptor", 1, QVector2D(0.0f, d), /*isEnemy=*/true);
|
||||
|
||||
// The ship already holds enemyA; enemyB is equidistant. Without self-exclusion
|
||||
// the ship's own claim would penalise enemyA and flip the choice.
|
||||
f.admin.get<AttackBehavior>(player).currentTarget = enemyA;
|
||||
|
||||
f.decide();
|
||||
|
||||
REQUIRE(f.admin.get<AttackBehavior>(player).currentTarget == enemyA);
|
||||
}
|
||||
|
||||
// When the held target becomes heavily overclaimed by others, its penalised score
|
||||
// drops far enough that an equidistant unclaimed enemy beats the hysteresis margin
|
||||
// and the ship switches.
|
||||
TEST_CASE("BehaviorSystem: a ship switches off a heavily overclaimed target",
|
||||
"[behavior]")
|
||||
{
|
||||
const float d = 10.0f;
|
||||
Fixture f;
|
||||
const entt::entity player = f.ships.spawn("interceptor", 1, QVector2D(0.0f, 0.0f));
|
||||
const entt::entity enemyA = f.ships.spawn("interceptor", 1, QVector2D(d, 0.0f),
|
||||
/*isEnemy=*/true);
|
||||
const entt::entity enemyB = f.ships.spawn("interceptor", 1, QVector2D(0.0f, d),
|
||||
/*isEnemy=*/true);
|
||||
|
||||
f.admin.get<AttackBehavior>(player).currentTarget = enemyA;
|
||||
|
||||
// Five other ships also commit to enemyA, saturating the claim penalty (0.5).
|
||||
for (int i = 0; i < 5; ++i)
|
||||
{
|
||||
const entt::entity other =
|
||||
f.ships.spawn("interceptor", 1, QVector2D(d, static_cast<float>(2 + i)));
|
||||
f.admin.get<AttackBehavior>(other).currentTarget = enemyA;
|
||||
}
|
||||
|
||||
f.decide();
|
||||
|
||||
REQUIRE(f.admin.get<AttackBehavior>(player).currentTarget == enemyB);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// AttackBehavior — enemy ships
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -84,6 +84,13 @@ TEST_CASE("ConfigLoader loads the committed bin/config/ configs end-to-end", "[c
|
||||
REQUIRE(cfg.world.waves.threatRateFormula.evaluate(1.0) == Approx(1.0));
|
||||
REQUIRE(cfg.world.waves.threatRateFormula.evaluate(5.0) == Approx(5.0));
|
||||
|
||||
// targeting: distance score 1/(1+x) and overclaim penalty max(0.5, 1-0.1*x).
|
||||
REQUIRE(cfg.world.targeting.hysteresis == Approx(0.10));
|
||||
REQUIRE(cfg.world.targeting.targetScoreFormula.evaluate(0.0) == Approx(1.0));
|
||||
REQUIRE(cfg.world.targeting.targetScoreFormula.evaluate(1.0) == Approx(0.5));
|
||||
REQUIRE(cfg.world.targeting.overclaimPenaltyFormula.evaluate(0.0) == Approx(1.0));
|
||||
REQUIRE(cfg.world.targeting.overclaimPenaltyFormula.evaluate(5.0) == Approx(0.5));
|
||||
|
||||
// buildings.toml
|
||||
REQUIRE(cfg.buildings.buildings.size() >= 8);
|
||||
const auto minerIt = std::find_if(
|
||||
|
||||
Reference in New Issue
Block a user