Files
EVEngine/test/sensing.cpp
T
西风逍遥游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

277 lines
11 KiB
C++

#include "sensing/Sensing.h"
#include "sensing/TargetingPipeline.h"
#include "common/Module.h"
#include "simplesquirrel/simplesquirrel.hpp"
#include "zeroerr/assert.h"
#include "zeroerr/unittest.h"
#include <cstddef>
#include <optional>
#include <string>
using namespace eve::sensing;
TEST_CASE("sensing.filtersAndSortsCandidates") {
SensingWorld w;
auto b = w.upsert("b", 3, 4, "red", "unit,tank", "p1");
auto a = w.upsert("a", 1, 0, "blue", "unit,infantry", "p1,p2");
auto c = w.upsert("c", 2, 0, "red", "building", "p1");
REQUIRE(b.ok());
REQUIRE(a.ok());
REQUIRE(c.ok());
auto count = w.circle(0, 0, 10, "unit", "", "", "blue", "p1", 8);
REQUIRE(count.ok());
CHECK_EQ(count.value(), 1);
auto candidate = w.resultAt(0);
REQUIRE(candidate.has_value());
CHECK_EQ(candidate->get().id, std::string("b"));
}
TEST_CASE("sensing.ordersByDistanceThenIdAndLimits") {
SensingWorld w;
auto z = w.upsert("z", 1, 0, "", "u", "");
auto a = w.upsert("a", -1, 0, "", "u", "");
REQUIRE(z.ok());
REQUIRE(a.ok());
auto count = w.circle(0, 0, 2, "u", "", "", "", "", 1);
REQUIRE(count.ok());
CHECK_EQ(count.value(), 1);
auto candidate = w.resultAt(0);
REQUIRE(candidate.has_value());
CHECK_EQ(candidate->get().id, std::string("a"));
}
TEST_CASE("sensing.snapshotIsTransactional") {
SensingWorld w;
auto inserted = w.upsert("u", 2, 3, "f", "b,a", "p");
REQUIRE(inserted.ok());
auto s = w.snapshotJson();
SensingWorld x;
auto restored = x.restoreJson(s);
REQUIRE(restored.ok());
CHECK_EQ(x.snapshotJson(), s);
auto before = x.snapshotJson();
auto rejected = x.restoreJson("{}");
CHECK(!rejected.ok());
CHECK_EQ(x.snapshotJson(), before);
}
TEST_CASE("sensing.querySpecRanksAndHonorsCountPolicy") {
SensingWorld w;
REQUIRE(w.upsert("near", 1, 0, "red", "unit", "").ok());
REQUIRE(w.upsert("mid", 3, 0, "red", "unit", "").ok());
REQUIRE(w.upsert("far", 9, 0, "red", "unit", "").ok());
QuerySpec truncate;
truncate.requiredTags = {"unit"};
truncate.minRange = 0.f;
truncate.maxRange = 10.f;
truncate.maxCount = 2;
truncate.countPolicy = CountPolicy::TruncateToMax;
truncate.sortKey = SortKey::DistanceAscending;
auto ranked = w.query(QueryOrigin{0.f, 0.f, std::nullopt}, truncate);
REQUIRE(ranked.ok());
CHECK_EQ(ranked.value().size(), 2u);
CHECK_EQ(ranked.value().ranked()[0].id, std::string("near"));
CHECK_EQ(ranked.value().ranked()[1].id, std::string("mid"));
CHECK_EQ(w.resultAt(0)->get().id, std::string("near"));
QuerySpec fail = truncate;
fail.countPolicy = CountPolicy::FailIfOutOfRange;
auto failed = w.query(QueryOrigin{0.f, 0.f, std::nullopt}, fail);
CHECK(!failed.ok());
CHECK_EQ(failed.code(), eve::StatusCode::Rejected);
QuerySpec sugarLike;
sugarLike.shape = QueryCircle{0.f, 0.f, 10.f};
sugarLike.requiredTags = {"unit"};
sugarLike.maxRange = 10.f;
sugarLike.maxCount = 8;
sugarLike.countPolicy = CountPolicy::TruncateToMax;
auto viaQuery = w.query(QueryOrigin{0.f, 0.f, std::nullopt}, sugarLike);
auto viaCircle = w.circle(0.f, 0.f, 10.f, "unit", "", "", "", "", 8);
REQUIRE(viaQuery.ok());
REQUIRE(viaCircle.ok());
CHECK_EQ(viaQuery.value().size(), static_cast<std::size_t>(viaCircle.value()));
}
TEST_CASE("sensing.queryConeFiltersByFacing") {
SensingWorld w;
REQUIRE(w.upsert("ahead", 5.f, 0.f, "red", "unit", "").ok());
REQUIRE(w.upsert("side", 0.f, 5.f, "red", "unit", "").ok());
REQUIRE(w.upsert("behind", -5.f, 0.f, "red", "unit", "").ok());
QuerySpec cone;
cone.shape = QueryCone{0.f, 0.f, 1.f, 0.f, 0.785398163f, 10.f};
cone.requiredTags = {"unit"};
cone.maxRange = 10.f;
cone.countPolicy = CountPolicy::TruncateToMax;
cone.sortKey = SortKey::DistanceAscending;
auto ranked = w.query(QueryOrigin{0.f, 0.f, std::nullopt}, cone);
REQUIRE(ranked.ok());
CHECK_EQ(ranked.value().size(), 1u);
CHECK_EQ(ranked.value().ranked()[0].id, std::string("ahead"));
}
TEST_CASE("sensing.executePresetRunsBuiltinConeSelect") {
SensingWorld w;
REQUIRE(w.upsert("ahead", 4.f, 0.f, "red", "unit", "").ok());
REQUIRE(w.upsert("left", 0.f, 4.f, "red", "unit", "").ok());
REQUIRE(w.upsert("farAhead", 40.f, 0.f, "red", "unit", "").ok());
TargetingPipeline pipeline = TargetingPipeline::withBuiltins();
TargetingPreset preset;
preset.id = "test.coneSelect";
{
TargetingTaskStep select;
select.taskId = "sensing.select.world";
select.querySpec.maxRange = 20.f;
select.querySpec.requiredTags = {"unit"};
select.querySpec.countPolicy = CountPolicy::TruncateToMax;
select.querySpec.sortKey = SortKey::None;
preset.steps.push_back(std::move(select));
}
{
TargetingTaskStep cone;
cone.taskId = "sensing.filter.cone";
cone.coneHalfAngle = 0.5f;
cone.coneRange = 20.f;
preset.steps.push_back(std::move(cone));
}
{
TargetingTaskStep sort;
sort.taskId = "sensing.sort.distance";
preset.steps.push_back(std::move(sort));
}
{
TargetingTaskStep truncate;
truncate.taskId = "sensing.sort.truncate";
truncate.truncateCount = 4;
preset.steps.push_back(std::move(truncate));
}
REQUIRE(pipeline.registerPreset(std::move(preset)).ok());
TargetingSourceContext context;
context.world = &w;
context.origin = QueryOrigin{0.f, 0.f, std::nullopt};
context.dirX = 1.f;
context.dirY = 0.f;
auto executed = pipeline.executePreset(context, "test.coneSelect");
REQUIRE(executed.ok());
CHECK_EQ(executed.value().size(), 1u);
CHECK_EQ(executed.value().ranked()[0].id, std::string("ahead"));
// Shared builtins path used by SensingWorld::executePreset
auto viaWorld = w.executePreset("sensing.builtin.coneSelect", 0.f, 0.f, 1.f, 0.f);
REQUIRE(viaWorld.ok());
CHECK_EQ(viaWorld.value(), 1);
CHECK_EQ(w.resultAt(0)->get().id, std::string("ahead"));
}
TEST_CASE("sensing.worldHandleAndScriptResultContract") {
auto created = Sensing::newWorld();
REQUIRE(created.ok());
const auto reference = std::move(created).takeValue();
auto borrowed = Sensing::resolve(reference);
REQUIRE(borrowed.isBound());
auto released = Sensing::release(reference);
REQUIRE(released.ok());
CHECK(Sensing::isStale(reference));
CHECK(!Sensing::resolve(reference).isBound());
ssq::VM vm(1024, ssq::Libs::ALL);
eve::ModuleManager::expose(vm);
vm.run(vm.compileSource(R"(
result <- "fail";
local sensing = eve.Sensing();
local created = sensing.newWorld();
if (created.ok && created.value.ownership() == "owned" && !created.value.isStale()) {
local world = created.value;
local inserted = world.upsert("script.subject", 0.0, 0.0, "red", "unit", "all");
local queried = world.circle(0.0, 0.0, 2.0, "unit", "", "", "", "all", 4);
local candidate = world.resultAt(0);
local snapshot = world.snapshotJson();
local restored = snapshot.ok ? world.restoreJson(snapshot.value) : { ok = false };
local rejected = world.restoreJson("{}");
local released = world.release();
if (inserted.ok && queried.ok && queried.value == 1 && candidate != null &&
snapshot.ok && restored.ok && !rejected.ok && released.ok && world.isStale())
result = "ok";
}
)"));
CHECK_EQ(vm.find("result").toString(), std::string("ok"));
}
TEST_CASE("sensing.perceptionFactsFromProjectsRankedCandidates") {
SensingWorld w;
REQUIRE(w.upsert("a", 1.f, 0.f, "red", "unit", "").ok());
REQUIRE(w.upsert("b", 3.f, 0.f, "red", "unit", "").ok());
QuerySpec spec;
spec.requiredTags = {"unit"};
spec.maxRange = 10.f;
spec.maxCount = 8;
spec.countPolicy = CountPolicy::TruncateToMax;
spec.sortKey = SortKey::DistanceAscending;
auto ranked = w.query(QueryOrigin{0.f, 0.f, std::nullopt}, spec);
REQUIRE(ranked.ok());
const auto facts = perceptionFactsFrom(ranked.value());
REQUIRE(facts.size() == 2u);
CHECK_EQ(facts[0].subjectId, std::string("a"));
CHECK_EQ(facts[1].subjectId, std::string("b"));
CHECK(facts[0].score > facts[1].score);
}
TEST_CASE("sensing.spatialIndexMatchesLinearScan") {
SensingWorld linear;
SensingWorld spatial;
REQUIRE(spatial.setSpatialIndexEnabled(true, 8.f).ok());
CHECK(spatial.spatialIndexEnabled());
CHECK(!linear.spatialIndexEnabled());
// Cluster near origin plus far distractors outside the circle broadphase.
REQUIRE(linear.upsert("near", 1.f, 0.f, "red", "unit", "").ok());
REQUIRE(linear.upsert("mid", 4.f, 0.f, "red", "unit", "").ok());
REQUIRE(linear.upsert("far", 50.f, 0.f, "red", "unit", "").ok());
REQUIRE(linear.upsert("noise", 80.f, 80.f, "blue", "prop", "").ok());
REQUIRE(spatial.upsert("near", 1.f, 0.f, "red", "unit", "").ok());
REQUIRE(spatial.upsert("mid", 4.f, 0.f, "red", "unit", "").ok());
REQUIRE(spatial.upsert("far", 50.f, 0.f, "red", "unit", "").ok());
REQUIRE(spatial.upsert("noise", 80.f, 80.f, "blue", "prop", "").ok());
QuerySpec spec;
spec.shape = QueryCircle{0.f, 0.f, 10.f};
spec.requiredTags = {"unit"};
spec.maxRange = 10.f;
spec.maxCount = 8;
spec.countPolicy = CountPolicy::TruncateToMax;
spec.sortKey = SortKey::DistanceAscending;
auto a = linear.query(QueryOrigin{0.f, 0.f, std::nullopt}, spec);
auto b = spatial.query(QueryOrigin{0.f, 0.f, std::nullopt}, spec);
REQUIRE(a.ok());
REQUIRE(b.ok());
REQUIRE(a.value().size() == b.value().size());
REQUIRE(a.value().size() == 2u);
CHECK_EQ(a.value().ranked()[0].id, b.value().ranked()[0].id);
CHECK_EQ(a.value().ranked()[1].id, b.value().ranked()[1].id);
const std::string debug = spatial.debugLastQueryJson();
CHECK(debug.find("\"schema\":\"eve.sensing.lastQuery\"") != std::string::npos);
CHECK(debug.find("\"used\":true") != std::string::npos);
CHECK(debug.find("\"enabled\":true") != std::string::npos);
CHECK(debug.find("\"id\":\"near\"") != std::string::npos);
// Broadphase should not visit the distant distractors.
const bool scannedTight = debug.find("\"scanned\":2") != std::string::npos ||
debug.find("\"scanned\":3") != std::string::npos;
CHECK(scannedTight);
}
TEST_CASE("sensing.spatialIndexRejectsInvalidCellSize") {
SensingWorld w;
auto bad = w.setSpatialIndexEnabled(true, 0.f);
CHECK(!bad.ok());
CHECK(!w.spatialIndexEnabled());
REQUIRE(w.setSpatialIndexEnabled(true, 16.f).ok());
CHECK(w.spatialIndexEnabled());
REQUIRE(w.setSpatialIndexEnabled(false).ok());
CHECK(!w.spatialIndexEnabled());
}