* 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>
277 lines
11 KiB
C++
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());
|
|
}
|