make ships claim targets

This commit is contained in:
2026-06-16 21:08:36 +02:00
parent 4153b7e2f5
commit ac97652c60
14 changed files with 268 additions and 31 deletions

View File

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

View File

@@ -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;
}

View File

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

View File

@@ -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;
};

View File

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

View File

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

View File

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

View File

@@ -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;
};

View File

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

View File

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

View File

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