Files
西风逍遥游andCursor Agent e8fbcbab55 feat(sensing): UE5-aligned QuerySpec, TargetingPipeline, RTS migration, spatial debug (#402)
* 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>
2026-09-15 15:17:58 +08:00

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