* docs(sensing): propose UE5-aligned targeting pipeline design Document gaps between EVEngine sensing/Targeting* and UE5 GameplayTargetingSystem, and a phased Preset/Task plan that keeps candidate scoring out of domain semantics. Co-authored-by: 西风逍遥游 <sunxfancy@gmail.com> * docs(sensing): make QuerySpec the unified SensingWorld query surface Revise the targeting design so SensingWorld owns one configurable QuerySpec path; Provider/Resolver become compatibility adapters, and Phase 1 targets semantic unification instead of a thin bridge only. Co-authored-by: 西风逍遥游 <sunxfancy@gmail.com> * feat(sensing): unify SensingWorld queries behind QuerySpec Add SensingWorld::query with CountPolicy/top-K ranking, route circle/box through it, and adapt Targeting via SensingWorldCandidateProvider so RTS and Action share one candidate fact source without inventing domain relations. Co-authored-by: 西风逍遥游 <sunxfancy@gmail.com> * feat(sensing): add TargetingPipeline presets, cone geometry, executePreset Phase 2 of the UE5-aligned targeting design: registerable Select/Filter/Sort tasks, QueryCone + WorldArea::cone2D, script executePreset, and usr docs. Co-authored-by: 西风逍遥游 <sunxfancy@gmail.com> * feat(sensing): migrate RTS acquisition to QuerySpec; add PerceptionFact bridge Phase 3 consumer work: CombatFireSystem builds QuerySpec instead of circle sugar while keeping RTS priority selection local. Add perceptionFactsFrom for npc_ai adapters; weapon lock-on remains deferred until that path exists. Co-authored-by: 西风逍遥游 <sunxfancy@gmail.com> * feat(sensing): optional SpatialHash2D broadphase and last-query debug JSON Add SensingWorld::setSpatialIndexEnabled for an opt-in SpatialHash2D cull before QuerySpec filtering, plus debugLastQueryJson for MCP/debug overlays. Keep sorting/filtering QuerySpec-owned; document Phase 4 and cover parity with linear scan in tests. Co-authored-by: 西风逍遥游 <sunxfancy@gmail.com> * feat(sensing): mirror zone membership onto TargetCandidate.zones Add SensingWorld::setZones for LogicalId membership facts, project them through SensingWorldCandidateProvider, and honor TargetingSpec.zone filters. Snapshot/restore and script bindings round-trip zones. Co-authored-by: 西风逍遥游 <sunxfancy@gmail.com> * feat(npc_ai): adapt sensing PerceptionFact into PerceptionMemory Add npc_ai/SensingPerception.h helpers that copy ranked sensing facts into adapter-owned perception memories without creating a sensing→npc_ai dependency. Co-authored-by: 西风逍遥游 <sunxfancy@gmail.com> * docs(sensing): document Candidate and owned-world script bindings Cover getId/getX/getY/getDistance, getName, and ownership/ownerEpoch/handle so Source quality --strict binding checks pass for the sensing module. Co-authored-by: 西风逍遥游 <sunxfancy@gmail.com> --------- Co-authored-by: Cursor Agent <cursoragent@cursor.com>
352 lines
15 KiB
C++
352 lines
15 KiB
C++
#include "sensing/Targeting.h"
|
|
#include "common/Capability.h"
|
|
#include "common/Identity.h"
|
|
#include "physics/TargetingLineOfSightAdapter.h"
|
|
#include "physics/World3D.h"
|
|
|
|
#include "zeroerr/assert.h"
|
|
#include "zeroerr/unittest.h"
|
|
|
|
#include <type_traits>
|
|
#include <utility>
|
|
|
|
namespace {
|
|
|
|
using eve::LogicalId;
|
|
using eve::PersistentId;
|
|
using eve::physics::TargetingLineOfSightAdapter;
|
|
using eve::physics::World3D;
|
|
using eve::sensing::CoordinateSpace;
|
|
using eve::sensing::CountPolicy;
|
|
using eve::sensing::GridPoint;
|
|
using eve::sensing::ILineOfSightQuery;
|
|
using eve::sensing::ISensingCandidateProvider;
|
|
using eve::sensing::LineOfSightResult;
|
|
using eve::sensing::SensingCandidateProvider;
|
|
using eve::sensing::SensingWorld;
|
|
using eve::sensing::SensingWorldCandidateProvider;
|
|
using eve::sensing::SubjectRef;
|
|
using eve::sensing::TargetCandidate;
|
|
using eve::sensing::TargetDomain;
|
|
using eve::sensing::TargetingQuery;
|
|
using eve::sensing::TargetingResolver;
|
|
using eve::sensing::TargetingSpec;
|
|
using eve::sensing::TargetLocation;
|
|
using eve::sensing::WorldArea;
|
|
using eve::sensing::WorldPoint;
|
|
|
|
SubjectRef subject(const char* text) {
|
|
auto id = PersistentId::parse(text);
|
|
REQUIRE(id.has_value());
|
|
return SubjectRef::fromPersistentId(*id);
|
|
}
|
|
|
|
WorldPoint world2D(float x, float y) {
|
|
auto point = WorldPoint::world2D(x, y);
|
|
return std::move(point).expect("test World2D point must be valid");
|
|
}
|
|
|
|
WorldPoint world3D(float x, float y, float z) {
|
|
auto point = WorldPoint::world3D(x, y, z);
|
|
return std::move(point).expect("test World3D point must be valid");
|
|
}
|
|
|
|
struct ResetCapabilities {
|
|
ResetCapabilities() { eve::cap::detail::clearAllRaw(); }
|
|
~ResetCapabilities() { eve::cap::detail::clearAllRaw(); }
|
|
};
|
|
|
|
class VisibleLineOfSight final : public ILineOfSightQuery {
|
|
public:
|
|
explicit VisibleLineOfSight(bool visible) : visible_(visible) {}
|
|
|
|
eve::Result<LineOfSightResult> query(const TargetLocation& from, const TargetLocation& to) const override {
|
|
if (from.index() != to.index())
|
|
return eve::Result<LineOfSightResult>::failure(eve::Diagnostic::error(
|
|
eve::DiagnosticCode::InvalidArgument, "test LOS received mixed coordinate spaces"));
|
|
if (from.index() == 1)
|
|
return eve::Result<LineOfSightResult>::failure(
|
|
eve::Diagnostic::error(eve::DiagnosticCode::Unsupported, "test LOS does not support grid coordinates"));
|
|
return eve::Result<LineOfSightResult>::success(LineOfSightResult{visible_, std::nullopt});
|
|
}
|
|
|
|
private:
|
|
bool visible_ = true;
|
|
};
|
|
|
|
} // namespace
|
|
|
|
TEST_CASE("targeting.typesKeepIdentityAndCoordinateSpacesSeparate") {
|
|
CHECK((!std::is_convertible_v<PersistentId, SubjectRef>));
|
|
CHECK((!std::is_convertible_v<SubjectRef, PersistentId>));
|
|
CHECK((!std::is_convertible_v<GridPoint, WorldPoint>));
|
|
|
|
const auto p2 = world2D(1.f, 2.f);
|
|
const auto p3 = world3D(1.f, 2.f, 3.f);
|
|
auto circle = WorldArea::circle2D(p2, 4.f);
|
|
REQUIRE(circle.ok());
|
|
CHECK_EQ(circle.value().space(), CoordinateSpace::World2D);
|
|
|
|
auto wrongCircle = WorldArea::circle2D(p3, 4.f);
|
|
CHECK(!wrongCircle.ok());
|
|
CHECK_EQ(wrongCircle.code(), eve::StatusCode::Rejected);
|
|
|
|
const auto grid = GridPoint::grid2D(1, 2);
|
|
CHECK_EQ(grid.space(), CoordinateSpace::Grid2D);
|
|
CHECK_NE(grid.space(), p2.space());
|
|
|
|
eve::sensing::TargetSet set;
|
|
std::move(set.setPoint(p2)).expect("target point");
|
|
auto gridArea = eve::sensing::GridArea::box2D(GridPoint::grid2D(0, 0), GridPoint::grid2D(2, 2));
|
|
REQUIRE(gridArea.ok());
|
|
auto mixedArea = set.setArea(std::move(gridArea).expect("grid area"));
|
|
CHECK(!mixedArea.ok());
|
|
CHECK_EQ(mixedArea.code(), eve::StatusCode::Rejected);
|
|
}
|
|
|
|
TEST_CASE("targeting.providerAndResolverApplyGenericConstraints") {
|
|
ResetCapabilities reset;
|
|
SensingCandidateProvider provider;
|
|
const auto origin = subject("00000000-0000-7000-8000-000000000001");
|
|
const auto enemy = subject("00000000-0000-7000-8000-000000000002");
|
|
const auto farEnemy = subject("00000000-0000-7000-8000-000000000003");
|
|
const auto ally = subject("00000000-0000-7000-8000-000000000004");
|
|
auto zoneId = LogicalId::parse("arena:central");
|
|
REQUIRE(zoneId.has_value());
|
|
auto zone = eve::sensing::ZoneRef::fromLogicalId(*zoneId);
|
|
REQUIRE(zone.has_value());
|
|
|
|
REQUIRE(
|
|
provider.upsert(TargetCandidate{enemy, world2D(3.f, 4.f), TargetDomain::Enemy, {"unit", "visible"}, {*zone}})
|
|
.ok());
|
|
REQUIRE(provider.upsert(TargetCandidate{farEnemy, world2D(50.f, 0.f), TargetDomain::Enemy, {"unit"}, {}}).ok());
|
|
REQUIRE(
|
|
provider.upsert(TargetCandidate{ally, world2D(2.f, 0.f), TargetDomain::Ally, {"unit", "visible"}, {}}).ok());
|
|
eve::cap::provide<ISensingCandidateProvider>(&provider);
|
|
|
|
TargetingSpec spec;
|
|
spec.space = CoordinateSpace::World2D;
|
|
spec.domain = TargetDomain::Enemy;
|
|
spec.minCount = 1;
|
|
spec.maxCount = 2;
|
|
spec.maxRange = 10.f;
|
|
spec.requiredTags = {"unit"};
|
|
spec.zone = *zone;
|
|
spec.worldArea = std::move(WorldArea::circle2D(world2D(0.f, 0.f), 10.f)).expect("area");
|
|
|
|
TargetingQuery query{origin, world2D(0.f, 0.f), spec};
|
|
auto resolved = TargetingResolver{}.resolve(query);
|
|
REQUIRE(resolved.ok());
|
|
CHECK_EQ(resolved.value().subjects().size(), 1u);
|
|
CHECK_EQ(resolved.value().subjects()[0], enemy);
|
|
CHECK(!resolved.value().primary().has_value());
|
|
}
|
|
|
|
TEST_CASE("targeting.missingCapabilitiesAreUnsupported") {
|
|
ResetCapabilities reset;
|
|
TargetingQuery query{subject("00000000-0000-7000-8000-000000000001"), world2D(0.f, 0.f), TargetingSpec{}};
|
|
|
|
auto noCandidates = TargetingResolver{}.resolve(query);
|
|
CHECK(!noCandidates.ok());
|
|
CHECK_EQ(noCandidates.code(), eve::StatusCode::Unsupported);
|
|
|
|
TargetingQuery mixed{query.origin, GridPoint::grid2D(0, 0), query.spec};
|
|
auto mixedResult = TargetingResolver{}.resolve(mixed);
|
|
CHECK(!mixedResult.ok());
|
|
CHECK_EQ(mixedResult.code(), eve::StatusCode::Rejected);
|
|
REQUIRE(mixedResult.error());
|
|
CHECK_EQ(mixedResult.error()->code(), eve::DiagnosticCode::InvalidArgument);
|
|
|
|
SensingCandidateProvider provider;
|
|
eve::cap::provide<ISensingCandidateProvider>(&provider);
|
|
query.spec.lineOfSight = eve::sensing::LineOfSightMode::Required;
|
|
auto noLos = TargetingResolver{}.resolve(query);
|
|
CHECK(!noLos.ok());
|
|
CHECK_EQ(noLos.code(), eve::StatusCode::Unsupported);
|
|
}
|
|
|
|
TEST_CASE("targeting.losCapabilityIsInjectedAndNeverConvertsGridCoordinates") {
|
|
ResetCapabilities reset;
|
|
SensingCandidateProvider provider;
|
|
const auto origin = subject("00000000-0000-7000-8000-000000000001");
|
|
const auto target = subject("00000000-0000-7000-8000-000000000002");
|
|
const auto gridTarget = subject("00000000-0000-7000-8000-000000000003");
|
|
REQUIRE(provider.upsert(TargetCandidate{target, world3D(1.f, 0.f, 0.f), TargetDomain::Enemy, {}, {}}).ok());
|
|
REQUIRE(provider.upsert(TargetCandidate{gridTarget, GridPoint::grid2D(1, 0), TargetDomain::Enemy, {}, {}}).ok());
|
|
eve::cap::provide<ISensingCandidateProvider>(&provider);
|
|
|
|
TargetingSpec spec;
|
|
spec.space = CoordinateSpace::World3D;
|
|
spec.domain = TargetDomain::Enemy;
|
|
spec.minCount = 1;
|
|
spec.maxCount = 1;
|
|
spec.maxRange = 10.f;
|
|
spec.lineOfSight = eve::sensing::LineOfSightMode::Required;
|
|
TargetingQuery query{origin, world3D(0.f, 0.f, 0.f), spec};
|
|
|
|
VisibleLineOfSight hidden(false);
|
|
eve::cap::provide<ILineOfSightQuery>(&hidden);
|
|
auto hiddenResult = TargetingResolver{}.resolve(query);
|
|
CHECK(!hiddenResult.ok());
|
|
CHECK_EQ(hiddenResult.code(), eve::StatusCode::Rejected);
|
|
REQUIRE(hiddenResult.error());
|
|
CHECK_EQ(hiddenResult.error()->code(), eve::DiagnosticCode::PreconditionViolation);
|
|
|
|
VisibleLineOfSight visible(true);
|
|
eve::cap::provide<ILineOfSightQuery>(&visible);
|
|
auto visibleResult = TargetingResolver{}.resolve(query);
|
|
REQUIRE(visibleResult.ok());
|
|
CHECK_EQ(visibleResult.value().subjects().size(), 1u);
|
|
|
|
TargetingSpec gridSpec;
|
|
gridSpec.space = CoordinateSpace::Grid2D;
|
|
gridSpec.minCount = 0;
|
|
gridSpec.maxCount = 1;
|
|
gridSpec.lineOfSight = eve::sensing::LineOfSightMode::Required;
|
|
TargetingQuery gridQuery{origin, GridPoint::grid2D(0, 0), gridSpec};
|
|
auto gridResult = TargetingResolver{}.resolve(gridQuery);
|
|
CHECK(!gridResult.ok());
|
|
CHECK_EQ(gridResult.code(), eve::StatusCode::Unsupported);
|
|
}
|
|
|
|
TEST_CASE("targeting.physicsAdapterRequiresOneActiveWorld") {
|
|
TargetingLineOfSightAdapter adapter;
|
|
const auto from = world3D(0.f, 0.f, 0.f);
|
|
const auto to = world3D(1.f, 0.f, 0.f);
|
|
|
|
auto noWorld = adapter.query(TargetLocation{from}, TargetLocation{to});
|
|
CHECK(!noWorld.ok());
|
|
CHECK_EQ(noWorld.code(), eve::StatusCode::Unsupported);
|
|
|
|
World3D first(0.f, 0.f, 0.f, false);
|
|
auto firstRegistration = adapter.addWorld(&first);
|
|
REQUIRE(firstRegistration.ok());
|
|
auto duplicateRegistration = adapter.addWorld(&first);
|
|
REQUIRE(duplicateRegistration.ok());
|
|
CHECK_EQ(duplicateRegistration.code(), eve::StatusCode::NoOp);
|
|
|
|
World3D second(0.f, 0.f, 0.f, false);
|
|
auto conflictingRegistration = adapter.addWorld(&second);
|
|
CHECK(!conflictingRegistration.ok());
|
|
CHECK_EQ(conflictingRegistration.code(), eve::StatusCode::Conflict);
|
|
|
|
auto present = adapter.query(TargetLocation{from}, TargetLocation{to});
|
|
REQUIRE(present.ok());
|
|
CHECK(present.value().visible);
|
|
|
|
first.destroy();
|
|
auto invalidWorld = adapter.query(TargetLocation{from}, TargetLocation{to});
|
|
CHECK(!invalidWorld.ok());
|
|
CHECK_EQ(invalidWorld.code(), eve::StatusCode::Unsupported);
|
|
|
|
auto removed = adapter.removeWorld(&first);
|
|
REQUIRE(removed.ok());
|
|
auto afterRemove = adapter.query(TargetLocation{from}, TargetLocation{to});
|
|
CHECK(!afterRemove.ok());
|
|
CHECK_EQ(afterRemove.code(), eve::StatusCode::Unsupported);
|
|
}
|
|
|
|
TEST_CASE("targeting.sensingWorldProviderSharesFactsAndRequiresDomainRelation") {
|
|
ResetCapabilities reset;
|
|
SensingWorld world;
|
|
const auto origin = subject("00000000-0000-7000-8000-000000000011");
|
|
const auto enemy = subject("00000000-0000-7000-8000-000000000012");
|
|
const auto ally = subject("00000000-0000-7000-8000-000000000013");
|
|
|
|
REQUIRE(world.upsert(origin.format(), 0.f, 0.f, "blue", "unit", "").ok());
|
|
REQUIRE(world.upsert(enemy.format(), 3.f, 4.f, "red", "unit", "").ok());
|
|
REQUIRE(world.upsert(ally.format(), 2.f, 0.f, "blue", "unit", "").ok());
|
|
|
|
SensingWorldCandidateProvider provider(world);
|
|
eve::cap::provide<ISensingCandidateProvider>(&provider);
|
|
|
|
TargetingSpec needsDomain;
|
|
needsDomain.space = CoordinateSpace::World2D;
|
|
needsDomain.domain = TargetDomain::Enemy;
|
|
needsDomain.minCount = 1;
|
|
needsDomain.maxCount = 2;
|
|
needsDomain.maxRange = 10.f;
|
|
needsDomain.requiredTags = {"unit"};
|
|
TargetingQuery blocked{origin, world2D(0.f, 0.f), needsDomain};
|
|
auto unsupported = TargetingResolver{}.resolve(blocked);
|
|
CHECK(!unsupported.ok());
|
|
CHECK_EQ(unsupported.code(), eve::StatusCode::Unsupported);
|
|
|
|
provider.setFactionRelation([](std::string_view originFaction,
|
|
std::string_view candidateFaction) -> eve::Result<TargetDomain> {
|
|
if (originFaction.empty() || candidateFaction.empty())
|
|
return eve::Result<TargetDomain>::success(TargetDomain::Neutral);
|
|
if (originFaction == candidateFaction) return eve::Result<TargetDomain>::success(TargetDomain::Ally);
|
|
return eve::Result<TargetDomain>::success(TargetDomain::Enemy);
|
|
});
|
|
|
|
auto resolved = TargetingResolver{}.resolve(blocked);
|
|
REQUIRE(resolved.ok());
|
|
CHECK_EQ(resolved.value().subjects().size(), 1u);
|
|
CHECK_EQ(resolved.value().subjects()[0], enemy);
|
|
|
|
TargetingSpec truncate = needsDomain;
|
|
truncate.domain = TargetDomain::Any;
|
|
truncate.maxCount = 1;
|
|
truncate.countPolicy = CountPolicy::TruncateToMax;
|
|
TargetingQuery nearest{origin, world2D(0.f, 0.f), truncate};
|
|
auto truncated = TargetingResolver{}.resolve(nearest);
|
|
REQUIRE(truncated.ok());
|
|
CHECK_EQ(truncated.value().subjects().size(), 1u);
|
|
CHECK_EQ(truncated.value().subjects()[0], ally);
|
|
}
|
|
|
|
TEST_CASE("targeting.worldAreaCone2DContainsFacingSector") {
|
|
auto apex = WorldPoint::world2D(0.f, 0.f);
|
|
REQUIRE(apex.ok());
|
|
auto cone = WorldArea::cone2D(apex.value(), 1.f, 0.f, 0.785398163f, 10.f);
|
|
REQUIRE(cone.ok());
|
|
CHECK(cone.value().contains(WorldPoint::world2D(5.f, 0.f).value()));
|
|
CHECK(!cone.value().contains(WorldPoint::world2D(0.f, 5.f).value()));
|
|
CHECK(!cone.value().contains(WorldPoint::world2D(-5.f, 0.f).value()));
|
|
|
|
auto bad = WorldArea::cone2D(apex.value(), 0.f, 0.f, 0.5f, 10.f);
|
|
CHECK(!bad.ok());
|
|
}
|
|
|
|
TEST_CASE("targeting.sensingWorldProviderHonorsZoneMembership") {
|
|
ResetCapabilities reset;
|
|
SensingWorld world;
|
|
const auto origin = subject("00000000-0000-7000-8000-000000000021");
|
|
const auto inside = subject("00000000-0000-7000-8000-000000000022");
|
|
const auto outside = subject("00000000-0000-7000-8000-000000000023");
|
|
|
|
REQUIRE(world.upsert(origin.format(), 0.f, 0.f, "blue", "unit", "").ok());
|
|
REQUIRE(world.upsert(inside.format(), 3.f, 0.f, "red", "unit", "").ok());
|
|
REQUIRE(world.upsert(outside.format(), 4.f, 0.f, "red", "unit", "").ok());
|
|
REQUIRE(world.setZones(inside.format(), "arena:central").ok());
|
|
REQUIRE(world.setZones(outside.format(), "arena:edge").ok());
|
|
CHECK(!world.setZones(outside.format(), "not-a-logical-id").ok());
|
|
|
|
auto zoneId = LogicalId::parse("arena:central");
|
|
REQUIRE(zoneId.has_value());
|
|
auto zone = eve::sensing::ZoneRef::fromLogicalId(*zoneId);
|
|
REQUIRE(zone.has_value());
|
|
|
|
SensingWorldCandidateProvider provider(world);
|
|
eve::cap::provide<ISensingCandidateProvider>(&provider);
|
|
provider.setFactionRelation([](std::string_view, std::string_view) {
|
|
return eve::Result<TargetDomain>::success(TargetDomain::Enemy);
|
|
});
|
|
|
|
TargetingSpec spec;
|
|
spec.space = CoordinateSpace::World2D;
|
|
spec.domain = TargetDomain::Enemy;
|
|
spec.minCount = 1;
|
|
spec.maxCount = 8;
|
|
spec.maxRange = 10.f;
|
|
spec.requiredTags = {"unit"};
|
|
spec.zone = *zone;
|
|
|
|
TargetingQuery query{origin, world2D(0.f, 0.f), spec};
|
|
auto resolved = TargetingResolver{}.resolve(query);
|
|
REQUIRE(resolved.ok());
|
|
CHECK_EQ(resolved.value().subjects().size(), 1u);
|
|
CHECK_EQ(resolved.value().subjects()[0], inside);
|
|
}
|