Files
EVEngine/test/npc_ai.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

379 lines
15 KiB
C++

#include "npc_ai/NpcAi.h"
#include "npc_ai/SensingPerception.h"
#include "sensing/Sensing.h"
#include "zeroerr/assert.h"
#include "zeroerr/unittest.h"
#include <map>
using namespace eve::npc_ai;
namespace {
class CountingTask final : public ITaskService {
public:
eve::Result<void> start(const TaskContext&, const TaskSpec& spec, std::string&) override {
++starts;
++startsById[spec.id];
return eve::Result<void>::success();
}
eve::Result<TaskStatus> tick(const TaskContext&, const TaskSpec& spec, std::string&) override {
++ticks;
return eve::Result<TaskStatus>::success(TaskStatus::Running);
}
void stop(const TaskContext& context, const TaskSpec& spec, StopReason, std::string_view) noexcept override {
++stops;
++stopsById[spec.id];
stopStates.push_back(std::string(context.stateId));
}
int starts = 0;
int ticks = 0;
int stops = 0;
std::map<std::string, int> startsById;
std::map<std::string, int> stopsById;
std::vector<std::string> stopStates;
};
class FailingStartTask final : public ITaskService {
public:
eve::Result<void> start(const TaskContext&, const TaskSpec&, std::string&) override {
return eve::Result<void>::failure(eve::Diagnostic::error(eve::DiagnosticCode::CallbackFailure,
"injected task start failure", {}, {}, "npc_ai.test"));
}
eve::Result<TaskStatus> tick(const TaskContext&, const TaskSpec&, std::string&) override {
return eve::Result<TaskStatus>::success(TaskStatus::Running);
}
void stop(const TaskContext&, const TaskSpec&, StopReason, std::string_view) noexcept override {}
};
BehaviorDefinition patrolBehavior() {
BehaviorDefinition result{"guard", 1, "idle", {}};
StateDefinition idle;
idle.id = "idle";
idle.tasks.push_back({"wait", "count"});
idle.transitions.push_back({"alert", "enemy_seen", {}, 10});
StateDefinition alert;
alert.id = "alert";
alert.enterConditions.push_back({"enemy", CompareOp::Exists, std::nullopt});
result.states = {idle, alert};
return result;
}
} // namespace
TEST_CASE("npc_ai.definitionValidationIsTransactional") {
NpcAiWorld world;
auto invalid = patrolBehavior();
invalid.states[1].id = "idle";
auto rejected = world.registerBehavior(std::move(invalid));
CHECK(!rejected.ok());
auto missing = world.createAgent("guard");
CHECK(!missing.ok());
auto accepted = world.registerBehavior(patrolBehavior());
REQUIRE(accepted.ok());
}
TEST_CASE("npc_ai.signalTransitionAndTaskLifecycle") {
NpcAiWorld world;
auto task = std::make_unique<CountingTask>();
auto* observed = task.get();
REQUIRE(world.registerTaskService("count", std::move(task)).ok());
REQUIRE(world.registerBehavior(patrolBehavior()).ok());
auto created = world.createAgent("guard");
REQUIRE(created.ok());
const auto agent = created.value();
REQUIRE(world.setBlackboard(agent, "enemy", std::string("player")).ok());
auto first = world.tick({1, 1.0 / 60.0, 1, 8});
REQUIRE(first.ok());
CHECK_EQ(observed->starts, 1);
CHECK_EQ(observed->ticks, 1);
REQUIRE(world.signal(agent, "enemy_seen").ok());
auto second = world.tick({2, 1.0 / 60.0, 1, 8});
REQUIRE(second.ok());
auto snapshot = world.snapshot(agent);
REQUIRE(snapshot.ok());
CHECK_EQ(snapshot.value().activeState, std::string("alert"));
CHECK_EQ(observed->stops, 1);
REQUIRE(world.destroyAgent(agent).ok());
auto stale = world.snapshot(agent);
CHECK(!stale.ok());
}
TEST_CASE("npc_ai.schedulerHonorsAgentBudget") {
NpcAiWorld world;
BehaviorDefinition behavior;
behavior.id = "idle";
behavior.initialState = "root";
behavior.states.push_back({"root"});
REQUIRE(world.registerBehavior(std::move(behavior)).ok());
REQUIRE(world.createAgent("idle").ok());
REQUIRE(world.createAgent("idle").ok());
auto report = world.tick({7, 0.1, 1, 2});
REQUIRE(report.ok());
CHECK_EQ(report.value().agentsUpdated, 1u);
CHECK_EQ(report.value().agentsDeferred, 1u);
}
TEST_CASE("npc_ai.hierarchicalPathKeepsParentTaskAcrossSiblingTransition") {
NpcAiWorld world;
auto task = std::make_unique<CountingTask>();
auto* observed = task.get();
REQUIRE(world.registerTaskService("count", std::move(task)).ok());
BehaviorDefinition behavior;
behavior.id = "hierarchy";
behavior.initialState = "patrol";
StateDefinition root;
root.id = "root";
root.tasks.push_back({"awareness", "count"});
StateDefinition patrol;
patrol.id = "patrol";
patrol.parent = "root";
patrol.tasks.push_back({"move", "count"});
patrol.transitions.push_back({"combat", "enemy_seen", {}, 1});
StateDefinition combat;
combat.id = "combat";
combat.parent = "root";
combat.tasks.push_back({"aim", "count"});
behavior.states = {root, patrol, combat};
REQUIRE(world.registerBehavior(std::move(behavior)).ok());
auto created = world.createAgent("hierarchy");
REQUIRE(created.ok());
REQUIRE(world.tick({1, 0.016, 1, 4}).ok());
REQUIRE(world.signal(created.value(), "enemy_seen").ok());
REQUIRE(world.tick({2, 0.016, 1, 4}).ok());
auto snapshot = world.snapshot(created.value());
REQUIRE(snapshot.ok());
CHECK_EQ(snapshot.value().activePath.size(), 2u);
CHECK_EQ(snapshot.value().activePath[0], std::string("root"));
CHECK_EQ(snapshot.value().activePath[1], std::string("combat"));
CHECK_EQ(observed->startsById["awareness"], 1);
CHECK_EQ(observed->stopsById["awareness"], 0);
CHECK_EQ(observed->stopsById["move"], 1);
CHECK_EQ(observed->startsById["aim"], 1);
}
TEST_CASE("npc_ai.failedEnterConditionDoesNotPartiallyTransition") {
NpcAiWorld world;
auto task = std::make_unique<CountingTask>();
auto* observed = task.get();
REQUIRE(world.registerTaskService("count", std::move(task)).ok());
REQUIRE(world.registerBehavior(patrolBehavior()).ok());
auto created = world.createAgent("guard");
REQUIRE(created.ok());
REQUIRE(world.tick({1, 0.016, 1, 4}).ok());
REQUIRE(world.signal(created.value(), "enemy_seen").ok());
REQUIRE(world.tick({2, 0.016, 1, 4}).ok());
auto snapshot = world.snapshot(created.value());
REQUIRE(snapshot.ok());
CHECK_EQ(snapshot.value().activeState, std::string("idle"));
CHECK_EQ(observed->stops, 0);
}
TEST_CASE("npc_ai.typedBlackboardAndPerceptionMemoryAreBounded") {
NpcAiWorld world({32, 2});
BehaviorDefinition behavior;
behavior.id = "perception";
behavior.initialState = "idle";
behavior.blackboardSchema.push_back({"alert", BlackboardType::Boolean, true, false});
behavior.states.push_back({"idle"});
REQUIRE(world.registerBehavior(std::move(behavior)).ok());
auto created = world.createAgent("perception");
REQUIRE(created.ok());
auto badType = world.setBlackboard(created.value(), "alert", std::string("yes"));
CHECK(!badType.ok());
REQUIRE(world.remember(created.value(), {"enemy-a", "sight", 0.8, 1, 10, "{}"}).ok());
REQUIRE(world.remember(created.value(), {"enemy-b", "hearing", 0.5, 2, 2, "{}"}).ok());
REQUIRE(world.remember(created.value(), {"enemy-c", "damage", 1.0, 3, 20, "{}"}).ok());
auto bounded = world.snapshot(created.value());
REQUIRE(bounded.ok());
CHECK_EQ(bounded.value().perception.size(), 2u);
REQUIRE(world.tick({4, 0.016, 1, 4}).ok());
auto expired = world.snapshot(created.value());
REQUIRE(expired.ok());
CHECK_EQ(expired.value().perception.size(), 1u);
auto trace = world.trace(created.value());
REQUIRE(trace.ok());
CHECK(trace.value().size() >= 5u);
}
TEST_CASE("npc_ai.taskAdmissionPreflightsAndRollsBackFailedStarts") {
BehaviorDefinition behavior;
behavior.id = "admission";
behavior.initialState = "root";
StateDefinition root;
root.id = "root";
root.tasks = {{"first", "count"}, {"second", "fail"}};
behavior.states = {root};
NpcAiWorld missingProviderWorld;
auto missingCounter = std::make_unique<CountingTask>();
auto* missingObserved = missingCounter.get();
REQUIRE(missingProviderWorld.registerTaskService("count", std::move(missingCounter)).ok());
REQUIRE(missingProviderWorld.registerBehavior(behavior).ok());
auto missingAgent = missingProviderWorld.createAgent("admission");
REQUIRE(missingAgent.ok());
auto missingTick = missingProviderWorld.tick({1, 0.016, 1, 4});
CHECK(!missingTick.ok());
CHECK_EQ(missingObserved->starts, 0);
NpcAiWorld rollbackWorld;
auto rollbackCounter = std::make_unique<CountingTask>();
auto* rollbackObserved = rollbackCounter.get();
REQUIRE(rollbackWorld.registerTaskService("count", std::move(rollbackCounter)).ok());
REQUIRE(rollbackWorld.registerTaskService("fail", std::make_unique<FailingStartTask>()).ok());
REQUIRE(rollbackWorld.registerBehavior(std::move(behavior)).ok());
auto rollbackAgent = rollbackWorld.createAgent("admission");
REQUIRE(rollbackAgent.ok());
auto rollbackTick = rollbackWorld.tick({1, 0.016, 1, 4});
CHECK(!rollbackTick.ok());
CHECK_EQ(rollbackObserved->starts, 1);
CHECK_EQ(rollbackObserved->stops, 1);
}
TEST_CASE("npc_ai.archiveRestoreIsVersionedAtomicAndRestartsProviders") {
NpcAiWorld world;
auto task = std::make_unique<CountingTask>();
auto* observed = task.get();
REQUIRE(world.registerTaskService("count", std::move(task)).ok());
REQUIRE(world.registerBehavior(patrolBehavior()).ok());
auto created = world.createAgent("guard");
REQUIRE(created.ok());
REQUIRE(world.setBlackboard(created.value(), "enemy", std::string("player")).ok());
REQUIRE(world.tick({11, 0.016, 1, 4}).ok());
CHECK_EQ(observed->starts, 1);
auto saved = world.archive(created.value());
REQUIRE(saved.ok());
auto incompatible = saved.value();
incompatible.schemaVersion = 99;
auto rejected = world.restoreAgent(incompatible);
CHECK(!rejected.ok());
CHECK(!world.isStale(created.value()));
auto restored = world.restoreAgent(saved.value());
REQUIRE(restored.ok());
CHECK_NE(restored.value(), created.value());
auto restoredSnapshot = world.snapshot(restored.value());
REQUIRE(restoredSnapshot.ok());
CHECK_EQ(restoredSnapshot.value().lastTick, 11u);
CHECK_EQ(restoredSnapshot.value().blackboard.at("enemy"), BlackboardValue(std::string("player")));
REQUIRE(world.tick({12, 0.016, 2, 4}).ok());
CHECK_EQ(observed->starts, 2);
}
TEST_CASE("npc_ai.largePopulationMakesBoundedRoundRobinProgress") {
constexpr std::uint32_t Population = 2048;
constexpr std::uint32_t Budget = 128;
NpcAiWorld world({64, 0});
BehaviorDefinition behavior;
behavior.id = "crowd";
behavior.initialState = "idle";
behavior.states.push_back({"idle"});
REQUIRE(world.registerBehavior(std::move(behavior)).ok());
std::vector<AgentHandle> agents;
agents.reserve(Population);
for (std::uint32_t index = 0; index < Population; ++index) {
auto created = world.createAgent("crowd");
REQUIRE(created.ok());
agents.push_back(created.value());
}
for (std::uint32_t batch = 0; batch < Population / Budget; ++batch) {
auto report = world.tick({batch + 1, 1.0 / 30.0, Budget, 2});
REQUIRE(report.ok());
CHECK_EQ(report.value().agentsUpdated, Budget);
CHECK_EQ(report.value().agentsDeferred, Population - Budget);
}
for (const auto agent : agents) {
auto state = world.snapshot(agent);
REQUIRE(state.ok());
CHECK(state.value().lastTick > 0);
}
}
TEST_CASE("npc_ai.transitionPriorityAppliesAcrossTheActiveHierarchy") {
NpcAiWorld world;
BehaviorDefinition behavior;
behavior.id = "priority";
behavior.initialState = "leaf";
StateDefinition root;
root.id = "root";
root.transitions.push_back({"high", "choose", {}, 10});
StateDefinition leaf;
leaf.id = "leaf";
leaf.parent = "root";
leaf.transitions.push_back({"low", "choose", {}, 1});
behavior.states = {root, leaf, {"low"}, {"high"}};
REQUIRE(world.registerBehavior(std::move(behavior)).ok());
auto agent = world.createAgent("priority");
REQUIRE(agent.ok());
REQUIRE(world.signal(agent.value(), "choose").ok());
REQUIRE(world.tick({1, 0.016, 1, 4}).ok());
auto state = world.snapshot(agent.value());
REQUIRE(state.ok());
CHECK_EQ(state.value().activeState, std::string("high"));
}
TEST_CASE("npc_ai.startupRollbackUsesEachTaskStateContext") {
NpcAiWorld world;
auto task = std::make_unique<CountingTask>();
auto* observed = task.get();
REQUIRE(world.registerTaskService("count", std::move(task)).ok());
REQUIRE(world.registerTaskService("fail", std::make_unique<FailingStartTask>()).ok());
BehaviorDefinition behavior;
behavior.id = "rollback-context";
behavior.initialState = "leaf";
StateDefinition root;
root.id = "root";
root.tasks.push_back({"parent-task", "count"});
StateDefinition leaf;
leaf.id = "leaf";
leaf.parent = "root";
leaf.tasks.push_back({"leaf-task", "fail"});
behavior.states = {root, leaf};
REQUIRE(world.registerBehavior(std::move(behavior)).ok());
auto agent = world.createAgent("rollback-context");
REQUIRE(agent.ok());
auto ticked = world.tick({1, 0.016, 1, 4});
CHECK(!ticked.ok());
REQUIRE_EQ(observed->stopStates.size(), 1u);
CHECK_EQ(observed->stopStates.front(), std::string("root"));
}
TEST_CASE("npc_ai.sensingPerceptionFactsProjectIntoMemory") {
eve::sensing::SensingWorld sensing;
REQUIRE(sensing.upsert("00000000-0000-7000-8000-000000000031", 1.f, 0.f, "red", "unit", "").ok());
REQUIRE(sensing.upsert("00000000-0000-7000-8000-000000000032", 4.f, 0.f, "red", "unit", "").ok());
eve::sensing::QuerySpec spec;
spec.requiredTags = {"unit"};
spec.maxRange = 10.f;
spec.maxCount = 8;
spec.countPolicy = eve::sensing::CountPolicy::TruncateToMax;
spec.sortKey = eve::sensing::SortKey::DistanceAscending;
auto ranked = sensing.query(eve::sensing::QueryOrigin{0.f, 0.f, std::nullopt}, spec);
REQUIRE(ranked.ok());
const auto facts = eve::sensing::perceptionFactsFrom(ranked.value());
REQUIRE_EQ(facts.size(), 2u);
NpcAiWorld world({32, 8});
BehaviorDefinition behavior;
behavior.id = "sensing-bridge";
behavior.initialState = "idle";
behavior.states.push_back({"idle"});
REQUIRE(world.registerBehavior(std::move(behavior)).ok());
auto created = world.createAgent("sensing-bridge");
REQUIRE(created.ok());
const auto memories = perceptionMemoriesFrom(facts, /*observedTick=*/10, /*forgetAfterTicks=*/20);
REQUIRE_EQ(memories.size(), 2u);
CHECK_EQ(memories[0].subject, facts[0].subjectId);
CHECK_EQ(memories[0].sense, std::string("sensing.query"));
CHECK(memories[0].confidence > memories[1].confidence);
REQUIRE(world.remember(created.value(), memories[0]).ok());
REQUIRE(world.remember(created.value(), memories[1]).ok());
auto snap = world.snapshot(created.value());
REQUIRE(snap.ok());
CHECK_EQ(snap.value().perception.size(), 2u);
}