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

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