make ships claim targets
This commit is contained in:
@@ -24,6 +24,11 @@ cost_building_blocks = 200
|
|||||||
push_expand_columns_tiles = 10
|
push_expand_columns_tiles = 10
|
||||||
boss_advance_seconds = 60
|
boss_advance_seconds = 60
|
||||||
|
|
||||||
|
[targeting]
|
||||||
|
target_score_formula = "1 / (1 + x)" # x = distance / max weapon range; higher = better, clamped to >=0
|
||||||
|
overclaim_penalty_formula = "max(0.5, 1 - 0.1*x)" # x = competing claim count; multiplies score, clamped to [0,1]
|
||||||
|
target_hysteresis = 0.10 # keep current target unless a challenger beats it by >10%
|
||||||
|
|
||||||
[waves]
|
[waves]
|
||||||
threat_rate_formula = "x"
|
threat_rate_formula = "x"
|
||||||
ship_level_formula = "1"
|
ship_level_formula = "1"
|
||||||
|
|||||||
@@ -24,6 +24,11 @@ cost_building_blocks = 200
|
|||||||
push_expand_columns_tiles = 20
|
push_expand_columns_tiles = 20
|
||||||
boss_advance_seconds = 60
|
boss_advance_seconds = 60
|
||||||
|
|
||||||
|
[targeting]
|
||||||
|
target_score_formula = "1 / (1 + x)" # x = distance / max weapon range; higher = better, clamped to >=0
|
||||||
|
overclaim_penalty_formula = "max(0.5, 1 - 0.1*x)" # x = competing claim count; multiplies score, clamped to [0,1]
|
||||||
|
target_hysteresis = 0.10 # keep current target unless a challenger beats it by >10%
|
||||||
|
|
||||||
[waves]
|
[waves]
|
||||||
threat_rate_formula = "x"
|
threat_rate_formula = "x"
|
||||||
ship_level_formula = "1 + x / 10"
|
ship_level_formula = "1 + x / 10"
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
|
|
||||||
Config files use the TOML format. The following config files drive game parameters:
|
Config files use the TOML format. The following config files drive game parameters:
|
||||||
|
|
||||||
- **world.toml** — world dimensions, region widths, expansion amounts, building refund percentage, wave timing, boss wave timing, enemy ship level formula, belt speed, starting building blocks, departure interval, ship orbit factor, rally orbit radius.
|
- **world.toml** — world dimensions, region widths, expansion amounts, building refund percentage, wave timing, boss wave timing, enemy ship level formula, belt speed, starting building blocks, departure interval, ship orbit factor, rally orbit radius, and combat target-selection parameters (target score formula, overclaim penalty formula, target hysteresis).
|
||||||
- **buildings.toml** — building block cost and construction time per building type.
|
- **buildings.toml** — building block cost and construction time per building type.
|
||||||
- **recipes.toml** — crafting recipes: inputs, outputs, quantities, durations, and reprocessing plant probabilities. Assembler recipe entries may optionally define `unlock_at_station_level` (integer): -1 means the recipe is explicitly unlocked at game start; a value ≥ 0 means the recipe starts locked and a schematic for it can be awarded via defence station destruction (see REQ-LOCK-EXPLICIT, REQ-DEF-SCHEMATIC-DROP).
|
- **recipes.toml** — crafting recipes: inputs, outputs, quantities, durations, and reprocessing plant probabilities. Assembler recipe entries may optionally define `unlock_at_station_level` (integer): -1 means the recipe is explicitly unlocked at game start; a value ≥ 0 means the recipe starts locked and a schematic for it can be awarded via defence station destruction (see REQ-LOCK-EXPLICIT, REQ-DEF-SCHEMATIC-DROP).
|
||||||
- **ships.toml** — per schematic: a human-readable display name (used in the UI), hull stats (HP, max linear speed, sensor range, main acceleration, maneuvering acceleration, angular acceleration, max rotation speed) as formulas of ship level, required build materials, player production level, the station level at which the schematic becomes available for unlock (`unlock_at_station_level`; -1 means the player starts with the schematic already unlocked), a layout grid defining the ship's module slots, a `scrap_drop` loot value, and a `default_modules` list used for enemy wave ships (see REQ-WAV-DEFAULT-MODULES).
|
- **ships.toml** — per schematic: a human-readable display name (used in the UI), hull stats (HP, max linear speed, sensor range, main acceleration, maneuvering acceleration, angular acceleration, max rotation speed) as formulas of ship level, required build materials, player production level, the station level at which the schematic becomes available for unlock (`unlock_at_station_level`; -1 means the player starts with the schematic already unlocked), a layout grid defining the ship's module slots, a `scrap_drop` loot value, and a `default_modules` list used for enemy wave ships (see REQ-WAV-DEFAULT-MODULES).
|
||||||
@@ -179,6 +179,9 @@ Modules in `modules.toml` define a `surface_mask` — a list of strings that des
|
|||||||
Each repair module instance operates independently: it has its own repair rate (`repair_rate`) and repair range (`repair_range`). On each tick, a module first attempts to heal the ship's current behavior-level navigation target if that target is within the module's `repair_range` and is damaged (HP above zero and below maximum HP). If those conditions are not met — because the target is out of the module's `repair_range`, already at full health, or destroyed — the module independently searches for the nearest damaged friendly (player ship or player defence station) within its own `repair_range` and heals that instead. If no valid target is found within range, the module idles. A ship with multiple repair modules can therefore heal different targets simultaneously. Navigation is driven solely by the behavior-level target; individual module fallback targets do not affect which direction the ship moves. Repair healing is a world-state change applied every tick regardless of which behavior the ship is currently executing.
|
Each repair module instance operates independently: it has its own repair rate (`repair_rate`) and repair range (`repair_range`). On each tick, a module first attempts to heal the ship's current behavior-level navigation target if that target is within the module's `repair_range` and is damaged (HP above zero and below maximum HP). If those conditions are not met — because the target is out of the module's `repair_range`, already at full health, or destroyed — the module independently searches for the nearest damaged friendly (player ship or player defence station) within its own `repair_range` and heals that instead. If no valid target is found within range, the module idles. A ship with multiple repair modules can therefore heal different targets simultaneously. Navigation is driven solely by the behavior-level target; individual module fallback targets do not affect which direction the ship moves. Repair healing is a world-state change applied every tick regardless of which behavior the ship is currently executing.
|
||||||
- REQ-SHP-RETREAT: **Player ships retreat to the rally point (REQ-SHP-RALLY) when threatened.** A ship retreats while either condition holds: (a) its HP is below a low-HP threshold (currently 30% of its maximum HP); or (b) it has no weapon modules and an enemy ship is within its sensor range. Retreating takes priority over the ship's other behaviors and moves it toward the rally point; the ship resumes its normal behavior once neither condition holds. Enemy ships never retreat (REQ-SHP-ENEMY-AI).
|
- REQ-SHP-RETREAT: **Player ships retreat to the rally point (REQ-SHP-RALLY) when threatened.** A ship retreats while either condition holds: (a) its HP is below a low-HP threshold (currently 30% of its maximum HP); or (b) it has no weapon modules and an enemy ship is within its sensor range. Retreating takes priority over the ship's other behaviors and moves it toward the rally point; the ship resumes its normal behavior once neither condition holds. Enemy ships never retreat (REQ-SHP-ENEMY-AI).
|
||||||
- REQ-SHP-ENEMY-AI: **Enemy ships** — engage the closest valid target (player defence station, HQ, or player ship) within their sensor range, orbiting the engaged target at the combat orbit radius (REQ-SHP-ORBIT). If no target is in sensor range, they move toward the asteroid (leftward in world coordinates).
|
- REQ-SHP-ENEMY-AI: **Enemy ships** — engage the closest valid target (player defence station, HQ, or player ship) within their sensor range, orbiting the engaged target at the combat orbit radius (REQ-SHP-ORBIT). If no target is in sensor range, they move toward the asteroid (leftward in world coordinates).
|
||||||
|
- REQ-SHP-TARGET-SELECT: **Combat target selection.** Both player combat ships (REQ-SHP-COMBAT) and enemy ships (REQ-SHP-ENEMY-AI) pick which hostile to engage by scoring every valid target (an opposing-faction ship, defence station, or HQ) within sensor range and engaging the highest-scoring one. A target's score is the product of a **base desirability** and an **overclaim penalty** (REQ-SHP-TARGET-CLAIM). The base desirability is `world.toml [targeting].target_score_formula` evaluated with `x` set to the target's distance from the ship divided by the ship's maximum weapon `attack_range` (falling back to sensor range for a ship with no weapon), clamped to a minimum of 0. The default formula `1 / (1 + x)` decreases with distance, so — absent any claims — the nearest target is chosen, realizing the closest-target priority referenced by REQ-SHP-COMBAT and REQ-SHP-ENEMY-AI. A ship engages at most one target at a time; all of its weapons fire on that target subject to their own range and rate checks (REQ-SHP-FIRING).
|
||||||
|
- REQ-SHP-TARGET-CLAIM: **Overclaim penalty.** To stop every ship from dogpiling the same hostile, each target a ship is currently engaging counts as a **claim** on that target. When scoring a candidate, its base desirability (REQ-SHP-TARGET-SELECT) is multiplied by `world.toml [targeting].overclaim_penalty_formula` evaluated with `x` set to the number of ships currently claiming that candidate — a ship never counts its own claim against the target it already holds — clamped to the range [0, 1]. The penalty is 1 (no reduction) at zero claims and decreases as claims accumulate, so heavily-claimed targets become less attractive and ships spread across the available hostiles. The default formula `max(0.5, 1 - 0.1*x)` reduces desirability by 0.1 per claim down to a floor of 0.5. Because claims reflect the previous tick's engagements, target distribution converges over successive ticks rather than instantaneously.
|
||||||
|
- REQ-SHP-TARGET-HYSTERESIS: **Target stickiness.** A ship keeps engaging its current target as long as that target remains valid and within sensor range, switching to a different target only when the best alternative's score exceeds the current target's score by more than the fractional margin `world.toml [targeting].target_hysteresis` (default 0.10). This prevents ships from rapidly oscillating between targets of near-equal score and preserves focus fire.
|
||||||
- REQ-SHP-SCHEMATICS: The player selects a schematic per shipyard by clicking it. New schematics are unlocked by destroying enemy defence station sets (REQ-DEF-SCHEMATIC-DROP) — there is no physical loot to collect.
|
- REQ-SHP-SCHEMATICS: The player selects a schematic per shipyard by clicking it. New schematics are unlocked by destroying enemy defence station sets (REQ-DEF-SCHEMATIC-DROP) — there is no physical loot to collect.
|
||||||
|
|
||||||
## Ship Modules
|
## Ship Modules
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ ArenaSimulation::ArenaSimulation(const GameConfig& gameConfig,
|
|||||||
// Arena fights are symmetric and aggressive: player-faction ships must not
|
// Arena fights are symmetric and aggressive: player-faction ships must not
|
||||||
// retreat (REQ-BAL-SIM-AI). Only one faction would otherwise get retreat.
|
// retreat (REQ-BAL-SIM-AI). Only one faction would otherwise get retreat.
|
||||||
m_shipSystem->setRetreatEnabled(false);
|
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_movementIntentSystem = std::make_unique<MovementIntentSystem>();
|
||||||
m_dynamicBodySystem = std::make_unique<DynamicBodySystem>();
|
m_dynamicBodySystem = std::make_unique<DynamicBodySystem>();
|
||||||
m_combatSystem = std::make_unique<CombatSystem>(m_gameConfig);
|
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");
|
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;
|
return cfg;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,14 @@
|
|||||||
|
|
||||||
#include "tinyexpr.h"
|
#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
|
Formula::Formula(Formula&& other) noexcept
|
||||||
: m_source(std::move(other.m_source))
|
: m_source(std::move(other.m_source))
|
||||||
, m_x(std::move(other.m_x))
|
, 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);
|
result.m_x = std::make_unique<double>(0.0);
|
||||||
|
|
||||||
const te_variable variables[] = {
|
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;
|
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)
|
if (result.m_expr == nullptr)
|
||||||
{
|
{
|
||||||
@@ -66,3 +77,4 @@ double Formula::evaluate(double x) const
|
|||||||
*m_x = x;
|
*m_x = x;
|
||||||
return te_eval(m_expr);
|
return te_eval(m_expr);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -39,6 +39,14 @@ struct WorldWaves
|
|||||||
double bossQuietAfterSeconds; // suppress normal waves this long after boss (REQ-WAV-QUIET)
|
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
|
struct WorldConfig
|
||||||
{
|
{
|
||||||
int heightTiles; // REQ-GW-HEIGHT
|
int heightTiles; // REQ-GW-HEIGHT
|
||||||
@@ -56,4 +64,5 @@ struct WorldConfig
|
|||||||
WorldExpansion expansion;
|
WorldExpansion expansion;
|
||||||
WorldPush push;
|
WorldPush push;
|
||||||
WorldWaves waves;
|
WorldWaves waves;
|
||||||
|
WorldTargeting targeting;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
#include <limits>
|
#include <limits>
|
||||||
|
|
||||||
|
#include "GameConfig.h"
|
||||||
|
|
||||||
#include "AdvanceBehavior.h"
|
#include "AdvanceBehavior.h"
|
||||||
#include "AttackBehavior.h"
|
#include "AttackBehavior.h"
|
||||||
#include "BehaviorKind.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,
|
void AiSystem::tick(EntityAdmin& admin, const BuildingSystem& buildings,
|
||||||
const ScrapSystem& scraps)
|
const ScrapSystem& scraps)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -18,6 +18,7 @@
|
|||||||
class BuildingSystem;
|
class BuildingSystem;
|
||||||
class EntityAdmin;
|
class EntityAdmin;
|
||||||
class ScrapSystem;
|
class ScrapSystem;
|
||||||
|
struct GameConfig;
|
||||||
|
|
||||||
// Orchestrates ship-behavior decision-making in three batched phases:
|
// Orchestrates ship-behavior decision-making in three batched phases:
|
||||||
// 1. evaluators score each behavior and set its target data,
|
// 1. evaluators score each behavior and set its target data,
|
||||||
@@ -29,6 +30,8 @@ class ScrapSystem;
|
|||||||
class AiSystem
|
class AiSystem
|
||||||
{
|
{
|
||||||
public:
|
public:
|
||||||
|
explicit AiSystem(const GameConfig& config);
|
||||||
|
|
||||||
void tick(EntityAdmin& admin, const BuildingSystem& buildings, const ScrapSystem& scraps);
|
void tick(EntityAdmin& admin, const BuildingSystem& buildings, const ScrapSystem& scraps);
|
||||||
|
|
||||||
private:
|
private:
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
#include "AttackEvaluator.h"
|
#include "AttackEvaluator.h"
|
||||||
|
|
||||||
|
#include <algorithm>
|
||||||
|
#include <unordered_map>
|
||||||
#include <vector>
|
#include <vector>
|
||||||
|
|
||||||
#include <QVector2D>
|
#include <QVector2D>
|
||||||
@@ -10,56 +12,130 @@
|
|||||||
#include "EntityAdmin.h"
|
#include "EntityAdmin.h"
|
||||||
#include "FactionComponent.h"
|
#include "FactionComponent.h"
|
||||||
#include "HealthComponent.h"
|
#include "HealthComponent.h"
|
||||||
|
#include "ModuleOwnerComponent.h"
|
||||||
#include "PositionComponent.h"
|
#include "PositionComponent.h"
|
||||||
#include "SensorRangeComponent.h"
|
#include "SensorRangeComponent.h"
|
||||||
#include "tracing.h"
|
#include "tracing.h"
|
||||||
|
#include "WeaponComponent.h"
|
||||||
|
#include "WorldConfig.h"
|
||||||
|
|
||||||
|
AttackEvaluator::AttackEvaluator(const WorldTargeting& targeting)
|
||||||
|
: m_targeting(&targeting)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
void AttackEvaluator::evaluate(EntityAdmin& admin)
|
void AttackEvaluator::evaluate(EntityAdmin& admin)
|
||||||
{
|
{
|
||||||
TRACE();
|
TRACE();
|
||||||
const std::vector<CombatantInfo> combatants = buildCombatants(admin);
|
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,
|
admin.forEach<AttackBehavior, PositionComponent, FactionComponent,
|
||||||
SensorRangeComponent, HealthComponent>(
|
SensorRangeComponent, HealthComponent>(
|
||||||
[&](entt::entity e, AttackBehavior& attack, const PositionComponent& pos,
|
[&](entt::entity e, AttackBehavior& attack, const PositionComponent& pos,
|
||||||
const FactionComponent& faction, const SensorRangeComponent& sensor,
|
const FactionComponent& faction, const SensorRangeComponent& sensor,
|
||||||
const HealthComponent& health)
|
const HealthComponent& health)
|
||||||
{
|
{
|
||||||
const float range = sensor.value_tiles;
|
const float sensorRange_tiles = sensor.value_tiles;
|
||||||
|
|
||||||
// Validate current target: still valid, still in range.
|
// Distance normaliser: max weapon range, or sensor range if unarmed.
|
||||||
bool targetValid = false;
|
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)
|
if (attack.currentTarget)
|
||||||
{
|
{
|
||||||
const entt::entity t = *attack.currentTarget;
|
const entt::entity t = *attack.currentTarget;
|
||||||
if (admin.isValid(t) && admin.hasAll<PositionComponent>(t))
|
if (admin.isValid(t) && admin.hasAll<PositionComponent>(t))
|
||||||
{
|
{
|
||||||
const float dist =
|
const QVector2D targetPos = admin.get<PositionComponent>(t).value;
|
||||||
(admin.get<PositionComponent>(t).value - pos.value).length();
|
const float dist = (targetPos - pos.value).length();
|
||||||
if (dist <= range) { targetValid = true; }
|
if (dist <= sensorRange_tiles)
|
||||||
|
{
|
||||||
|
const float currentScore = scoreOf(targetPos, t);
|
||||||
|
const float margin = 1.0f + static_cast<float>(m_targeting->hysteresis);
|
||||||
|
if (!bestTarget || bestScore <= currentScore * margin)
|
||||||
|
{
|
||||||
|
keptCurrent = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Acquire nearest valid target if needed.
|
if (!keptCurrent) { attack.currentTarget = bestTarget; }
|
||||||
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 =
|
const bool healthy =
|
||||||
(health.maxHp > 0.0f)
|
(health.maxHp > 0.0f)
|
||||||
|
|||||||
@@ -1,11 +1,22 @@
|
|||||||
#pragma once
|
#pragma once
|
||||||
|
|
||||||
class EntityAdmin;
|
class EntityAdmin;
|
||||||
|
struct WorldTargeting;
|
||||||
|
|
||||||
// Acquires/validates a combat target for ships with weapons. Scores high only
|
// 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.
|
// 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
|
class AttackEvaluator
|
||||||
{
|
{
|
||||||
public:
|
public:
|
||||||
|
explicit AttackEvaluator(const WorldTargeting& targeting);
|
||||||
|
|
||||||
void evaluate(EntityAdmin& admin);
|
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); },
|
[this](const std::string& itemId) -> bool { return isItemUnlocked(itemId); },
|
||||||
m_rng);
|
m_rng);
|
||||||
m_shipSystem = std::make_unique<ShipSystem>(m_config, m_admin);
|
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_movementIntentSystem = std::make_unique<MovementIntentSystem>();
|
||||||
m_dynamicBodySystem = std::make_unique<DynamicBodySystem>();
|
m_dynamicBodySystem = std::make_unique<DynamicBodySystem>();
|
||||||
m_scrapSystem = std::make_unique<ScrapSystem>(m_admin);
|
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); },
|
[this](const std::string& itemId) -> bool { return isItemUnlocked(itemId); },
|
||||||
m_rng);
|
m_rng);
|
||||||
m_shipSystem = std::make_unique<ShipSystem>(m_config, m_admin);
|
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_movementIntentSystem = std::make_unique<MovementIntentSystem>();
|
||||||
m_dynamicBodySystem = std::make_unique<DynamicBodySystem>();
|
m_dynamicBodySystem = std::make_unique<DynamicBodySystem>();
|
||||||
m_scrapSystem = std::make_unique<ScrapSystem>(m_admin);
|
m_scrapSystem = std::make_unique<ScrapSystem>(m_admin);
|
||||||
|
|||||||
@@ -83,6 +83,7 @@ struct Fixture
|
|||||||
[](const std::string&) -> bool { return true; },
|
[](const std::string&) -> bool { return true; },
|
||||||
rng)
|
rng)
|
||||||
, ships(cfg, admin)
|
, ships(cfg, admin)
|
||||||
|
, ai(cfg)
|
||||||
, salvager(admin)
|
, salvager(admin)
|
||||||
, repair(admin)
|
, repair(admin)
|
||||||
, scraps(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());
|
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
|
// 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(1.0) == Approx(1.0));
|
||||||
REQUIRE(cfg.world.waves.threatRateFormula.evaluate(5.0) == Approx(5.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
|
// buildings.toml
|
||||||
REQUIRE(cfg.buildings.buildings.size() >= 8);
|
REQUIRE(cfg.buildings.buildings.size() >= 8);
|
||||||
const auto minerIt = std::find_if(
|
const auto minerIt = std::find_if(
|
||||||
|
|||||||
Reference in New Issue
Block a user