16 [op](
const auto&
a,
const auto&
b) {
17 using A = std::decay_t<
decltype(
a)>;
18 using B = std::decay_t<
decltype(
b)>;
19 if constexpr (!std::is_same_v<A, B> || std::is_same_v<A, bool>)
27 default:
return false;
41bool validPredicate(
const BlackboardPredicate& predicate) {
42 return !predicate.key.empty() && (predicate.op ==
CompareOp::Exists || predicate.value.has_value());
49 if (definition.schemaVersion != 1)
51 "unsupported NPC behavior schema version", {}, {},
"npc_ai"));
52 if (definition.id.empty() || definition.initialState.empty())
54 "behavior id and initial state are required", {}, {},
"npc_ai"));
55 std::set<std::string>
ids;
56 std::set<std::string> schemaKeys;
57 for (
const auto&
key : definition.blackboardSchema) {
58 if (
key.key.empty() || !schemaKeys.insert(
key.key).second)
60 "blackboard schema keys must be non-empty and unique", {},
62 if (
key.defaultValue && valueType(*
key.defaultValue) !=
key.type)
64 "blackboard default value does not match its declared type",
66 if (
key.required && !
key.defaultValue)
71 for (
const auto&
state : definition.states) {
74 "state ids must be non-empty and unique", {}, {},
"npc_ai"));
75 std::set<std::string> taskIds;
76 for (
const auto& task :
state.tasks)
77 if (task.id.empty() || task.type.empty() || !taskIds.insert(task.id).second)
82 if (!
ids.contains(definition.initialState))
85 for (
const auto&
state : definition.states) {
89 for (
const auto& transition :
state.transitions) {
90 if (!
ids.contains(transition.targetState))
93 if (transition.targetState ==
state.id && transition.signal.empty())
96 "an unconditional self-transition would exhaust the transition budget", {}, {},
"npc_ai"));
97 for (
const auto& predicate : transition.conditions)
98 if (!validPredicate(predicate))
100 "invalid transition predicate", {}, {},
"npc_ai"));
102 for (
const auto& predicate :
state.enterConditions)
103 if (!validPredicate(predicate))
107 for (
const auto&
origin : definition.states) {
108 std::set<std::string> chain;
111 if (!chain.insert(
current->id).second)
113 "state parent cycle detected", {}, {},
"npc_ai"));
114 auto it = std::find_if(definition.states.begin(), definition.states.end(),
115 [&](
const auto&
s) { return s.id == *current->parent; });
123 auto checked =
validate(definition);
125 if (behaviors_.contains(definition.id)) {
128 "behavior definition already exists; replacement requires an explicit migration", {}, {},
"npc_ai"));
130 behaviors_.emplace(definition.id, std::move(definition));
137 "task service type and owner are required", {}, {},
"npc_ai"));
138 taskServices_.insert_or_assign(std::move(
type), std::move(
service));
143 auto behavior = behaviors_.find(std::string(behaviorId));
144 if (behavior == behaviors_.end())
148 if (freeSlots_.empty()) {
149 index =
static_cast<std::uint32_t
>(slots_.size());
150 slots_.push_back({});
152 index = freeSlots_.back();
153 freeSlots_.pop_back();
156 candidate.behaviorId = behavior->first;
157 candidate.activeState = behavior->second.initialState;
158 for (
const auto&
key : behavior->second.blackboardSchema)
159 if (
key.defaultValue) candidate.blackboard.emplace(
key.key, *
key.defaultValue);
160 for (
const auto state : activePath(behavior->second, candidate.activeState)) {
161 if (!predicatesPass(
state.get().enterConditions, candidate)) {
162 freeSlots_.push_back(
index);
164 "initial state path enter conditions are false", {},
168 slots_[
index].agent = std::move(candidate);
175 if (!
handle.isValid() ||
handle.index() >= slots_.size())
178 auto& slot = slots_[
handle.index()];
179 if (!slot.agent || slot.generation !=
handle.generation())
180 return Result<std::reference_wrapper<Agent>>::failure(
182 return Result<std::reference_wrapper<Agent>>::success(std::ref(*slot.agent));
184Result<std::reference_wrapper<const NpcAiWorld::Agent>> NpcAiWorld::resolve(
AgentHandle handle)
const {
185 if (!
handle.isValid() ||
handle.index() >= slots_.size())
186 return Result<std::reference_wrapper<const Agent>>::failure(
188 const auto& slot = slots_[
handle.index()];
189 if (!slot.agent || slot.generation !=
handle.generation())
190 return Result<std::reference_wrapper<const Agent>>::failure(
192 return Result<std::reference_wrapper<const Agent>>::success(std::cref(*slot.agent));
196 auto resolved = resolve(
handle);
198 Agent& agent = resolved.value().get();
199 const auto& behavior = behaviors_.at(agent.behaviorId);
200 auto path = activePath(behavior, agent.activeState);
204 auto& slot = slots_[
handle.index()];
208 slot.generation = *next;
209 freeSlots_.push_back(
handle.index());
218 const std::string traceKey =
key;
219 auto resolved = resolve(
handle);
221 auto& agent = resolved.value().get();
222 const auto& behavior = behaviors_.at(agent.behaviorId);
223 const auto schema = std::find_if(behavior.blackboardSchema.begin(), behavior.blackboardSchema.end(),
224 [&](
const auto& item) { return item.key == key; });
225 if (!behavior.blackboardSchema.empty() && schema == behavior.blackboardSchema.end())
228 if (schema != behavior.blackboardSchema.end() && schema->type != valueType(
value))
231 agent.blackboard.insert_or_assign(std::move(
key), std::move(
value));
237 if (signalName.empty())
240 auto resolved = resolve(
handle);
242 auto& agent = resolved.value().get();
243 agent.signals.push_back(std::move(signalName));
249 if (
memory.subject.empty() ||
memory.sense.empty() || !std::isfinite(
memory.confidence) ||
252 "perception memory fields are invalid", {}, {},
"npc_ai"));
253 auto resolved = resolve(
handle);
255 auto& agent = resolved.value().get();
256 auto found = std::find_if(agent.perception.begin(), agent.perception.end(), [&](
const auto& item) {
257 return item.subject == memory.subject && item.sense == memory.sense;
259 if (
found != agent.perception.end()) {
267 std::min_element(agent.perception.begin(), agent.perception.end(), [](
const auto&
a,
const auto&
b) {
268 return std::pair{a.observedTick, a.subject +
"\n" + a.sense} <
269 std::pair{b.observedTick, b.subject +
"\n" + b.sense};
274 agent.perception.push_back(
memory);
277 std::sort(agent.perception.begin(), agent.perception.end(), [](
const auto&
a,
const auto&
b) {
278 return std::pair{a.subject, a.sense} < std::pair{b.subject, b.sense};
280 agent.signals.push_back(
"perception." +
memory.sense);
286 if (
subject.empty() || sense.empty())
288 "perception subject and sense are required", {}, {},
"npc_ai"));
289 auto resolved = resolve(
handle);
291 auto& agent = resolved.value().get();
292 auto& memories = agent.perception;
293 const auto found = std::find_if(memories.begin(), memories.end(),
294 [&](
const auto& item) { return item.subject == subject && item.sense == sense; });
295 if (
found == memories.end())
298 pushTrace({agent.lastTick,
handle, TraceKind::PerceptionForgotten,
found->subject,
found->sense});
299 memories.erase(
found);
303bool NpcAiWorld::predicatesPass(std::span<const BlackboardPredicate> predicates,
const Agent& agent)
const {
304 for (
const auto& predicate : predicates) {
305 const auto found = agent.blackboard.find(predicate.key);
306 if (predicate.op == CompareOp::Exists) {
307 if (
found == agent.blackboard.end())
return false;
310 if (
found == agent.blackboard.end() || !predicate.value ||
311 !compareValues(
found->second, predicate.op, *predicate.value))
317std::optional<std::reference_wrapper<const StateDefinition>> NpcAiWorld::findState(
const BehaviorDefinition& behavior,
318 std::string_view
id)
const {
320 std::find_if(behavior.states.begin(), behavior.states.end(), [&](
const auto& state) { return state.id == id; });
321 if (
found == behavior.states.end())
return std::nullopt;
322 return std::cref(*
found);
325std::vector<std::reference_wrapper<const StateDefinition>> NpcAiWorld::activePath(
const BehaviorDefinition& behavior,
326 std::string_view leaf)
const {
327 std::vector<std::reference_wrapper<const StateDefinition>>
path;
328 auto current = findState(behavior, leaf);
333 std::reverse(
path.begin(),
path.end());
337std::string NpcAiWorld::taskRuntimeKey(std::string_view state, std::string_view task) {
339 result.reserve(
state.size() + task.size() + 1);
340 result.append(state);
341 result.push_back(
'\n');
346void NpcAiWorld::pushTrace(TraceEvent event) {
347 if (config_.traceCapacity == 0)
return;
348 if (trace_.size() == config_.traceCapacity) trace_.pop_front();
349 trace_.push_back(std::move(event));
352void NpcAiWorld::stopStateTasks(AgentHandle
handle, Agent& agent,
const StateDefinition& state,
353 StopReason reason)
noexcept {
354 for (
const auto& task :
state.tasks) {
355 const auto key = taskRuntimeKey(
state.id, task.id);
356 auto runtime = agent.taskRuntime.find(
key);
357 auto service = taskServices_.find(task.type);
358 if (runtime != agent.taskRuntime.end() && runtime->second.started && service != taskServices_.end()) {
359 const TaskContext
context{
handle,
state.id, agent.lastTick, 0.0, agent.blackboard, agent.perception};
360 service->second->stop(
context, task, reason, runtime->second.memoryJson);
362 agent.taskRuntime.erase(
key);
367 if (!std::isfinite(
context.deltaSeconds) ||
context.deltaSeconds < 0.0 ||
context.maxTransitionsPerAgent == 0)
372 std::uint32_t visited = 0;
374 const std::uint32_t
index = (scheduleCursor_ + visited) %
static_cast<std::uint32_t
>(slots_.size());
376 auto& slot = slots_[
index];
377 if (!slot.agent)
continue;
379 Agent& agent = *slot.agent;
380 auto& behavior = behaviors_.at(agent.behaviorId);
381 auto path = activePath(behavior, agent.activeState);
384 "agent active state is missing", {}, {},
"npc_ai"));
386 for (
auto memory = agent.perception.begin();
memory != agent.perception.end();) {
387 const bool expired =
context.simulationTick >=
memory->observedTick &&
393 const std::string signalName =
"perception.forgotten." +
memory->sense;
394 agent.signals.push_back(signalName);
399 std::uint32_t transitions = 0;
400 while (transitions <
context.maxTransitionsPerAgent) {
402 std::vector<std::reference_wrapper<const StateDefinition>> selectedPath;
404 for (
const auto& candidate :
state->get().transitions) {
405 const bool signalMatches =
406 candidate.
signal.empty() ||
407 std::find(agent.signals.begin(), agent.signals.end(), candidate.signal) != agent.signals.end();
408 if (!signalMatches || !predicatesPass(candidate.conditions, agent) ||
411 auto candidatePath = activePath(behavior, candidate.targetState);
412 const bool canEnter = std::all_of(
413 candidatePath.begin(), candidatePath.end(),
414 [&](
const auto target) { return predicatesPass(target.get().enterConditions, agent); });
416 selected = &candidate;
417 selectedPath = std::move(candidatePath);
421 if (!selected)
break;
423 std::size_t commonPrefix = 0;
424 while (commonPrefix <
path.size() && commonPrefix < selectedPath.size() &&
425 path[commonPrefix].get().id == selectedPath[commonPrefix].get().id)
427 for (std::size_t exiting =
path.size(); exiting > commonPrefix; --exiting)
428 stopStateTasks(
handle, agent,
path[exiting - 1].get(), StopReason::Transition);
430 const std::string previousState = agent.activeState;
432 path = std::move(selectedPath);
435 pushTrace({
context.simulationTick,
handle, TraceKind::TransitionApplied, previousState, agent.activeState});
436 if (!selected->
signal.empty())
437 agent.signals.erase(std::remove(agent.signals.begin(), agent.signals.end(), selected->
signal),
438 agent.signals.end());
441 agent.signals.clear();
442 agent.lastTick =
context.simulationTick;
445 for (
const auto& task :
state.get().tasks)
446 if (!taskServices_.contains(task.type))
451 std::reference_wrapper<const StateDefinition>
state;
452 std::reference_wrapper<const TaskSpec> task;
453 std::string runtimeKey;
455 std::vector<StartedTask> startedThisTick;
457 for (
const auto& task :
state.get().tasks) {
458 auto&
service = taskServices_.at(task.type);
459 const auto runtimeKey = taskRuntimeKey(
state.get().id, task.id);
460 auto& runtime = agent.taskRuntime[runtimeKey];
461 if (runtime.completed)
continue;
463 agent.blackboard, agent.perception};
464 if (!runtime.started) {
465 auto started =
service->start(taskContext, task, runtime.memoryJson);
467 agent.taskRuntime.erase(runtimeKey);
468 for (
auto rollback = startedThisTick.rbegin(); rollback != startedThisTick.rend(); ++rollback) {
469 auto active = agent.taskRuntime.find(rollback->runtimeKey);
470 if (
active == agent.taskRuntime.end())
continue;
471 auto& rollbackService = taskServices_.at(rollback->task.get().type);
473 rollback->state.get().id,
478 rollbackService->stop(rollbackContext, rollback->task.get(), StopReason::StartupRollback,
479 active->second.memoryJson);
480 agent.taskRuntime.erase(
active);
484 runtime.started =
true;
485 startedThisTick.push_back({
state, std::cref(task), runtimeKey});
486 pushTrace({
context.simulationTick,
handle, TraceKind::TaskStarted,
state.get().
id, task.id});
492 for (
const auto& task :
state.get().tasks) {
493 auto&
service = taskServices_.at(task.type);
494 auto& runtime = agent.taskRuntime.at(taskRuntimeKey(
state.get().id, task.id));
495 if (runtime.completed)
continue;
497 agent.blackboard, agent.perception};
498 auto taskResult =
service->tick(taskContext, task, runtime.memoryJson);
501 if (taskResult.value() != TaskStatus::Running) {
502 service->stop(taskContext, task, StopReason::Completed, runtime.memoryJson);
503 runtime.started =
false;
504 runtime.completed =
true;
505 const bool succeeded = taskResult.value() == TaskStatus::Succeeded;
506 agent.signals.push_back(std::string(
"task.") + (succeeded ?
"succeeded." :
"failed.") + task.id);
508 succeeded ? TraceKind::TaskCompleted : TraceKind::TaskFailed,
state.get().
id, task.id});
514 scheduleCursor_ = (scheduleCursor_ + visited) %
static_cast<std::uint32_t
>(slots_.size());
515 for (
const auto& slot : slots_)
522 auto resolved = resolve(
handle);
524 const Agent& agent = resolved.value().get();
525 std::vector<std::string>
path;
526 for (
const auto state : activePath(behaviors_.at(agent.behaviorId), agent.activeState))
529 agent.blackboard, agent.perception, agent.lastTick});
533 auto resolved = resolve(
handle);
535 const Agent& agent = resolved.value().get();
543 const auto& behavior = behaviors_.at(agent.behaviorId);
544 for (
const auto state : activePath(behavior, agent.activeState)) {
545 for (
const auto& task :
state.get().tasks) {
546 const auto runtime = agent.taskRuntime.find(taskRuntimeKey(
state.get().id, task.id));
547 if (runtime == agent.taskRuntime.end())
continue;
548 result.
tasks.push_back({
state.get().id, task.id, runtime->second.completed, runtime->second.memoryJson});
555 if (archive.
schemaId != AgentArchive::SchemaId || archive.
schemaVersion != AgentArchive::SchemaVersion)
558 const auto behaviorIt = behaviors_.find(archive.
behaviorId);
559 if (behaviorIt == behaviors_.end())
562 const auto& behavior = behaviorIt->second;
566 if (archive.
perception.size() > config_.maxMemoriesPerAgent)
576 candidate.lastTick = archive.
lastTick;
577 for (
const auto& signalName : candidate.signals)
578 if (signalName.empty())
581 std::set<std::pair<std::string, std::string>> memoryKeys;
582 for (
const auto&
memory : candidate.perception) {
583 if (
memory.subject.empty() ||
memory.sense.empty() || !std::isfinite(
memory.confidence) ||
587 if (!memoryKeys.emplace(
memory.subject,
memory.sense).second)
592 if (!behavior.blackboardSchema.empty()) {
593 if (candidate.blackboard.size() > behavior.blackboardSchema.size())
596 for (
const auto& [
key,
value] : candidate.blackboard) {
597 const auto spec = std::find_if(behavior.blackboardSchema.begin(), behavior.blackboardSchema.end(),
598 [&](
const auto& item) { return item.key == key; });
599 if (spec == behavior.blackboardSchema.end() || spec->type != valueType(
value))
602 "archive blackboard does not match the behavior schema", {}, {},
"npc_ai"));
604 for (
const auto& spec : behavior.blackboardSchema)
605 if (spec.required && !candidate.blackboard.contains(spec.key))
610 const auto path = activePath(behavior, candidate.activeState);
611 if (!std::all_of(
path.begin(),
path.end(),
612 [&](
const auto state) { return predicatesPass(state.get().enterConditions, candidate); }))
615 std::set<std::string> validTasks;
617 for (
const auto& task :
state.get().tasks) validTasks.insert(taskRuntimeKey(
state.get().id, task.id));
618 std::set<std::string> archivedTasks;
619 for (
const auto& task : archive.
tasks) {
620 const auto key = taskRuntimeKey(task.stateId, task.taskId);
621 if (!validTasks.contains(
key) || !archivedTasks.insert(
key).second)
624 "archive task is duplicated or outside the active path", {}, {},
"npc_ai"));
625 candidate.taskRuntime.emplace(
key, TaskRuntime{
false, task.completed, task.memoryJson});
627 std::sort(candidate.perception.begin(), candidate.perception.end(), [](
const auto&
a,
const auto&
b) {
628 return std::pair{a.subject, a.sense} < std::pair{b.subject, b.sense};
632 if (freeSlots_.empty()) {
633 index =
static_cast<std::uint32_t
>(slots_.size());
634 slots_.push_back({});
636 index = freeSlots_.back();
637 freeSlots_.pop_back();
639 slots_[
index].agent = std::move(candidate);
641 pushTrace({archive.lastTick,
handle, TraceKind::AgentRestored, archive.behaviorId, archive.activeState});
646 if (!
handle.isValid() ||
handle.index() >= slots_.size())
return true;
647 const auto& slot = slots_[
handle.index()];
648 return !slot.agent || slot.generation !=
handle.generation();
652 auto resolved = resolve(
handle);
654 std::vector<TraceEvent> result;
655 for (
const auto& event : trace_)
656 if (event.agent ==
handle && event.tick >= fromTick) result.push_back(event);
vk::UniqueDeviceMemory memory
Structured operation status used by the common Result foundation.
const VegetationPresetContext & context
static Diagnostic error(DiagnosticCode code, std::string message, std::string path={}, DiagnosticDetails details={}, std::string source={})
Construct an error diagnostic with the standard error severity.
Move-only operation result carrying either a value or Status.
static Result success(T value)
Construct a successful result owning value.
static Result failure(Status status)
Construct a failed result from a structured status.
constexpr std::optional< RuntimeHandle > nextGeneration() const noexcept
Returns this slot at its next generation.
static Status success(StatusCode code=StatusCode::Ok)
Construct a successful status with an explicit non-error outcome.
Result< AgentHandle > createAgent(std::string_view behaviorId)
Creates an agent bound to an existing behavior definition.
Result< void > remember(AgentHandle handle, PerceptionMemory memory)
Upserts one bounded memory and queues a sense-specific wake-up signal.
Result< void > setBlackboard(AgentHandle handle, std::string key, BlackboardValue value)
Writes one authoritative blackboard value.
static Result< void > validate(const BehaviorDefinition &definition)
Validates definition shape without publishing it.
Result< void > signal(AgentHandle handle, std::string signalName)
Queues a named wake-up signal for evaluation on the next tick.
NpcAiWorld(NpcAiWorldConfig config={})
Npc ai world.
Result< void > registerBehavior(BehaviorDefinition definition)
Validates and atomically publishes a behavior definition.
Result< void > registerTaskService(std::string type, std::unique_ptr< ITaskService > service)
Transfers ownership of a task provider into this world.
Result< void > destroyAgent(AgentHandle handle)
Stops active tasks and invalidates the agent handle.
eve::RuntimeHandle< AgentHandleTag > AgentHandle
std::variant< bool, std::int64_t, double, std::string > BlackboardValue
BlackboardType
BlackboardType public API.
CompareOp
CompareOp public API.
Versioned, owning NPC state used for save/load and hot-reload handoff.
std::map< std::string, BlackboardValue > blackboard
std::vector< TaskArchive > tasks
std::vector< std::string > pendingSignals
std::vector< PerceptionMemory > perception
std::uint32_t schemaVersion
Immutable, validated source data for one hierarchical NPC state tree.
NpcAiWorldConfig public API.
std::size_t maxMemoriesPerAgent
One bounded, expiring item of NPC perception memory.
StateDefinition public API.
std::uint64_t simulationTick
Input time and work limits for one deterministic simulation update.
std::uint32_t transitionsApplied
std::uint32_t agentsUpdated
std::uint32_t transitionBudgetsExhausted
std::uint32_t tasksTicked
std::uint32_t agentsDeferred