tess 0.4.0
Performance-first tile and path simulation substrate
Loading...
Searching...
No Matches
path_agent.h
1#pragma once
2
3#include <tess/path/path_runtime.h>
4#include <tess/path/precheck.h>
5#include <tess/sim/movement.h>
6
7#include <concepts>
8#include <cstddef>
9#include <cstdint>
10#include <span>
11
12namespace tess {
13
14// Lifecycle of a path agent, decoupled from the last PathStatus result:
15// - Idle: no goal (or arrived); the agent does not consume processing.
16// - NeedsPath: a goal was assigned and no route has been computed yet.
17// - Following: a Found route is being walked tile by tile.
18// - Blocked: the last step or path attempt hit a transient failure; the
19// agent re-paths on the next tick until its retry budget runs out.
20// - Unreachable: the retry budget was exhausted or a structural movement
21// failure occurred; terminal until a new goal is assigned.
23enum class PathAgentPhase : std::uint8_t {
24 Idle,
25 NeedsPath,
26 Following,
27 Blocked,
28 Unreachable,
29};
30
33 Coord3 position{};
34 Coord3 goal{};
35 PathTicket ticket{};
36 std::size_t path_index = 0;
37 PathStatus status = PathStatus::NoPath;
38 PathAgentPhase phase = PathAgentPhase::Idle;
39 bool has_goal = false;
40 std::uint32_t blocked_retries = 0;
41};
42
45 std::size_t submitted = 0;
46 std::size_t completed = 0;
47 std::size_t found = 0;
48 std::size_t invalid_start = 0;
49 std::size_t invalid_goal = 0;
50 std::size_t no_path = 0;
51 // Sparse worlds: the search stopped at the resident-set boundary without
52 // ruling out a route through a non-resident chunk
53 // (PathStatus::Indeterminate).
54 std::size_t indeterminate = 0;
55 // Agents whose goal an optional topology precheck proved unreachable before
56 // A* (a subset of no_path). See PathRuntimeStats::precheck_ruled_out.
57 std::size_t precheck_ruled_out = 0;
58 std::size_t advanced = 0;
59 std::size_t arrived = 0;
60 std::size_t blocked_waits = 0;
61 MovementFailureCounts movement_failures{};
62};
63
64// Which agents a processing pass (re)submits (per-agent pathing dirt,
65// optimization-log 2026-07-11/12):
66// - All: every agent with a goal replans -- required after a WORLD change,
67// which can invalidate any existing route.
68// - NeedsOnly: only agents that cannot advance without a plan (NeedsPath,
69// Blocked); Following agents keep walking their retained routes. One
70// agent arming a goal no longer replans the whole batch.
72enum class PathSubmitScope : std::uint8_t {
73 All,
74 NeedsOnly,
75};
76
77// Per-agent route retention, index-paired with the agents span handed to
78// the tick drivers: routes[i] is agents[i]'s current Found route (empty
79// otherwise). Retention is what makes PathSubmitScope::NeedsOnly sound --
80// the runtime rebuilds its result storage every processing pass, so a
81// non-resubmitted agent's route must live here. Vectors keep their
82// capacity across replans, so warm ticks stay allocation-free.
83//
84// CONTRACT: the pairing is by span index. A caller that reorders, removes,
85// or compacts its agents between ticks must call mark_pathing_dirty on the
86// tick state (forcing one full replan) or keep routes[] in sync itself.
89 std::vector<std::vector<Coord3>> routes;
90
91 void ensure_size(std::size_t count) {
92 if (routes.size() < count) {
93 routes.resize(count);
94 }
95 }
96};
97
99inline void set_path_agent_goal(PathAgentState& agent, Coord3 goal) noexcept {
100 agent.goal = goal;
101 agent.path_index = 0;
102 agent.status = PathStatus::NoPath;
103 agent.phase = PathAgentPhase::NeedsPath;
104 agent.blocked_retries = 0;
105 agent.has_goal = true;
106}
107
109inline void clear_path_agent_goal(PathAgentState& agent) noexcept {
110 agent.goal = {};
111 agent.ticket = {};
112 agent.path_index = 0;
113 agent.status = PathStatus::NoPath;
114 agent.phase = PathAgentPhase::Idle;
115 agent.blocked_retries = 0;
116 agent.has_goal = false;
117}
118
120inline auto submit_path_agents(std::span<PathAgentState> agents,
121 PathRequestRuntime& runtime,
122 PathSubmitScope scope = PathSubmitScope::All)
125 runtime.clear_requests();
126
127 for (auto& agent : agents) {
128 if (scope == PathSubmitScope::NeedsOnly &&
129 agent.phase == PathAgentPhase::Following) {
130 // Keeps its retained route and path_index; the runtime rebuild below
131 // makes its old ticket stale, which nothing reads in the scoped flow.
132 continue;
133 }
134 agent.path_index = 0;
135 if (!agent.has_goal || agent.phase == PathAgentPhase::Unreachable) {
136 continue;
137 }
138 if (agent.position == agent.goal) {
139 clear_path_agent_goal(agent);
140 ++stats.arrived;
141 continue;
142 }
143 agent.ticket = runtime.submit(PathRequest{agent.position, agent.goal});
144 ++stats.submitted;
145 }
146
147 return stats;
148}
149
151inline void record_path_agent_status(PathAgentFrameStats& stats,
152 PathStatus status) noexcept {
153 switch (status) {
154 case PathStatus::Found:
155 ++stats.found;
156 return;
157 case PathStatus::InvalidStart:
158 ++stats.invalid_start;
159 return;
160 case PathStatus::InvalidGoal:
161 ++stats.invalid_goal;
162 return;
163 case PathStatus::NoPath:
164 ++stats.no_path;
165 return;
166 case PathStatus::Indeterminate:
167 ++stats.indeterminate;
168 return;
169 }
170}
171
174inline auto apply_path_agent_results(std::span<PathAgentState> agents,
175 const PathRequestRuntime& runtime,
176 PathSubmitScope scope,
177 PathAgentRoutes* routes)
180 if (routes != nullptr) {
181 // Callers going through the tick drivers arrive pre-sized; grow here
182 // too so the public process_* overloads cannot index out of bounds.
183 routes->ensure_size(agents.size());
184 }
185
186 for (std::size_t i = 0; i < agents.size(); ++i) {
187 auto& agent = agents[i];
188 if (scope == PathSubmitScope::NeedsOnly &&
189 agent.phase == PathAgentPhase::Following) {
190 // Not resubmitted by the matching scoped submit; its runtime ticket
191 // is stale and its retained route stays as-is.
192 continue;
193 }
194 if (!agent.has_goal || agent.position == agent.goal ||
195 agent.phase == PathAgentPhase::Unreachable) {
196 continue;
197 }
198
199 const auto result = runtime.result(agent.ticket);
200 agent.status = result.status;
201 agent.path_index = 0;
202 if (result.status == PathStatus::Found) {
203 agent.phase = PathAgentPhase::Following;
204 agent.blocked_retries = 0;
205 if (routes != nullptr) {
206 routes->routes[i].assign(result.path.begin(), result.path.end());
207 }
208 } else {
209 // Planner failures are retried through the Blocked lifecycle until
210 // the tick driver's retry budget runs out.
211 agent.phase = PathAgentPhase::Blocked;
212 if (routes != nullptr) {
213 routes->routes[i].clear();
214 }
215 }
216 ++stats.completed;
217 record_path_agent_status(stats, result.status);
218 }
219
220 return stats;
221}
222
224inline auto apply_path_agent_results(std::span<PathAgentState> agents,
225 const PathRequestRuntime& runtime)
227 return apply_path_agent_results(agents, runtime, PathSubmitScope::All,
228 nullptr);
229}
230
232inline auto advance_path_agents(std::span<PathAgentState> agents,
233 const PathRequestRuntime& runtime,
234 std::size_t max_steps = 1)
237 if (max_steps == 0) {
238 return stats;
239 }
240
241 for (auto& agent : agents) {
242 if (!agent.has_goal || agent.status != PathStatus::Found) {
243 continue;
244 }
245
246 const auto result = runtime.result(agent.ticket);
247 if (result.status != PathStatus::Found || result.path.empty()) {
248 continue;
249 }
250
251 for (std::size_t step = 0; step < max_steps; ++step) {
252 if (agent.path_index + 1 >= result.path.size()) {
253 break;
254 }
255 ++agent.path_index;
256 agent.position = result.path[agent.path_index];
257 ++stats.advanced;
258 if (agent.position == agent.goal) {
259 clear_path_agent_goal(agent);
260 agent.status = PathStatus::Found;
261 ++stats.arrived;
262 break;
263 }
264 }
265 }
266
267 return stats;
268}
269
270// Observer form: `on_commit(agent_index, from, to)` is invoked once per
271// successful commit_movement_intent, after the agent's position and the
272// world's occupancy fields are updated and before arrival handling. It is
273// never invoked for a failed validation (nothing was written to the world),
274// so external tile->entity mirrors that update inside the callback stay
275// synchronized with the occupancy field by construction.
276template <typename World, typename ClassOrTag, typename OccupancyTag,
277 typename ReservationTag, typename OnCommit>
278 requires std::invocable<OnCommit&, std::size_t, Coord3, Coord3>
280inline auto advance_path_agents_with_movement(World& world,
281 std::span<PathAgentState> agents,
282 const PathRequestRuntime& runtime,
283 std::size_t max_steps,
284 std::uint32_t movement_dirty_mask,
285 OnCommit&& on_commit)
288 if (max_steps == 0) {
289 return stats;
290 }
291
292 for (std::size_t agent_index = 0; agent_index < agents.size();
293 ++agent_index) {
294 auto& agent = agents[agent_index];
295 if (!agent.has_goal || agent.status != PathStatus::Found) {
296 continue;
297 }
298
299 const auto result = runtime.result(agent.ticket);
300 if (result.status != PathStatus::Found || result.path.empty()) {
301 continue;
302 }
303
304 for (std::size_t step = 0; step < max_steps; ++step) {
305 if (agent.path_index + 1 >= result.path.size()) {
306 break;
307 }
308
309 const auto from = agent.position;
310 const auto to = result.path[agent.path_index + 1];
311 const auto movement =
312 commit_movement_intent<World, ClassOrTag, OccupancyTag,
313 ReservationTag>(
314 world, MovementIntent{from, to, {}}, movement_dirty_mask);
315 if (movement.status != MovementStatus::Moved) {
316 record_movement_failure(stats.movement_failures, movement.status);
317 if (is_transient_movement_failure(movement.status)) {
318 // The route is still notionally valid; wait in place and let the
319 // tick driver schedule a re-path. Found status is retained so a
320 // freed tile can be walked without a fresh plan. The blocked
321 // step itself does not consume re-path budget: only the tick
322 // driver's prepare_path_agent_processing counts attempts, so a
323 // budget of N grants N re-paths even when the cycle started
324 // with a movement block (see PathAgentTickOptions::
325 // max_blocked_retries for the full budget semantics).
326 agent.phase = PathAgentPhase::Blocked;
327 ++stats.blocked_waits;
328 } else {
329 // Invalid endpoints or a non-adjacent step indicate a caller
330 // bug; terminal until a new goal re-arms the lifecycle.
331 agent.status = PathStatus::NoPath;
332 agent.phase = PathAgentPhase::Unreachable;
333 }
334 break;
335 }
336
337 ++agent.path_index;
338 agent.position = to;
339 on_commit(agent_index, from, to);
340 ++stats.advanced;
341 if (agent.position == agent.goal) {
342 clear_path_agent_goal(agent);
343 agent.status = PathStatus::Found;
344 ++stats.arrived;
345 break;
346 }
347 }
348 }
349
350 return stats;
351}
352
353template <typename World, typename ClassOrTag, typename OccupancyTag,
354 typename ReservationTag>
356inline auto advance_path_agents_with_movement(
357 World& world, std::span<PathAgentState> agents,
358 const PathRequestRuntime& runtime, std::size_t max_steps = 1,
359 std::uint32_t movement_dirty_mask = 0) -> PathAgentFrameStats {
360 return advance_path_agents_with_movement<World, ClassOrTag, OccupancyTag,
361 ReservationTag>(
362 world, agents, runtime, max_steps, movement_dirty_mask,
363 [](std::size_t, Coord3, Coord3) {});
364}
365
366// Route-pool advance: identical stepping semantics to the runtime-reading
367// overloads above, but the route comes from the retained pool, so it
368// survives processing passes that did not resubmit this agent
369// (PathSubmitScope::NeedsOnly).
371inline auto advance_path_agents(std::span<PathAgentState> agents,
372 const PathAgentRoutes& routes,
373 std::size_t max_steps = 1)
375 // The pool is const here, so it must already cover the span (the tick
376 // drivers ensure_size before processing; direct callers must too).
377 TESS_ASSERT(routes.routes.size() >= agents.size());
379 if (max_steps == 0) {
380 return stats;
381 }
382
383 for (std::size_t i = 0; i < agents.size(); ++i) {
384 auto& agent = agents[i];
385 if (!agent.has_goal || agent.status != PathStatus::Found) {
386 continue;
387 }
388
389 const auto& route = routes.routes[i];
390 if (route.empty()) {
391 continue;
392 }
393
394 for (std::size_t step = 0; step < max_steps; ++step) {
395 if (agent.path_index + 1 >= route.size()) {
396 break;
397 }
398 ++agent.path_index;
399 agent.position = route[agent.path_index];
400 ++stats.advanced;
401 if (agent.position == agent.goal) {
402 clear_path_agent_goal(agent);
403 agent.status = PathStatus::Found;
404 ++stats.arrived;
405 break;
406 }
407 }
408 }
409
410 return stats;
411}
412
413template <typename World, typename ClassOrTag, typename OccupancyTag,
414 typename ReservationTag, typename OnCommit>
415 requires std::invocable<OnCommit&, std::size_t, Coord3, Coord3>
417inline auto advance_path_agents_with_movement(World& world,
418 std::span<PathAgentState> agents,
419 const PathAgentRoutes& routes,
420 std::size_t max_steps,
421 std::uint32_t movement_dirty_mask,
422 OnCommit&& on_commit)
424 // Same pool-coverage precondition as the plain route-pool advance.
425 TESS_ASSERT(routes.routes.size() >= agents.size());
427 if (max_steps == 0) {
428 return stats;
429 }
430
431 for (std::size_t agent_index = 0; agent_index < agents.size();
432 ++agent_index) {
433 auto& agent = agents[agent_index];
434 if (!agent.has_goal || agent.status != PathStatus::Found) {
435 continue;
436 }
437
438 const auto& route = routes.routes[agent_index];
439 if (route.empty()) {
440 continue;
441 }
442
443 for (std::size_t step = 0; step < max_steps; ++step) {
444 if (agent.path_index + 1 >= route.size()) {
445 break;
446 }
447
448 const auto from = agent.position;
449 const auto to = route[agent.path_index + 1];
450 const auto movement =
451 commit_movement_intent<World, ClassOrTag, OccupancyTag,
452 ReservationTag>(
453 world, MovementIntent{from, to, {}}, movement_dirty_mask);
454 if (movement.status != MovementStatus::Moved) {
455 record_movement_failure(stats.movement_failures, movement.status);
456 if (is_transient_movement_failure(movement.status)) {
457 // Same Blocked/Unreachable split as the runtime-reading overload;
458 // see its comment for the retry-budget semantics.
459 agent.phase = PathAgentPhase::Blocked;
460 ++stats.blocked_waits;
461 } else {
462 agent.status = PathStatus::NoPath;
463 agent.phase = PathAgentPhase::Unreachable;
464 }
465 break;
466 }
467
468 ++agent.path_index;
469 agent.position = to;
470 on_commit(agent_index, from, to);
471 ++stats.advanced;
472 if (agent.position == agent.goal) {
473 clear_path_agent_goal(agent);
474 agent.status = PathStatus::Found;
475 ++stats.arrived;
476 break;
477 }
478 }
479 }
480
481 return stats;
482}
483
484template <typename World, typename ClassOrTag, typename OccupancyTag,
485 typename ReservationTag>
487inline auto advance_path_agents_with_movement(
488 World& world, std::span<PathAgentState> agents,
489 const PathAgentRoutes& routes, std::size_t max_steps = 1,
490 std::uint32_t movement_dirty_mask = 0) -> PathAgentFrameStats {
491 return advance_path_agents_with_movement<World, ClassOrTag, OccupancyTag,
492 ReservationTag>(
493 world, agents, routes, max_steps, movement_dirty_mask,
494 [](std::size_t, Coord3, Coord3) {});
495}
496
498inline void add_path_agent_stats(PathAgentFrameStats& lhs,
499 PathAgentFrameStats rhs) noexcept {
500 lhs.submitted += rhs.submitted;
501 lhs.completed += rhs.completed;
502 lhs.found += rhs.found;
503 lhs.invalid_start += rhs.invalid_start;
504 lhs.invalid_goal += rhs.invalid_goal;
505 lhs.no_path += rhs.no_path;
506 lhs.indeterminate += rhs.indeterminate;
507 lhs.precheck_ruled_out += rhs.precheck_ruled_out;
508 lhs.advanced += rhs.advanced;
509 lhs.arrived += rhs.arrived;
510 lhs.blocked_waits += rhs.blocked_waits;
511 lhs.movement_failures.invalid += rhs.movement_failures.invalid;
512 lhs.movement_failures.blocked += rhs.movement_failures.blocked;
513 lhs.movement_failures.occupied += rhs.movement_failures.occupied;
514 lhs.movement_failures.reserved += rhs.movement_failures.reserved;
515 lhs.movement_failures.stale_version += rhs.movement_failures.stale_version;
516 lhs.movement_failures.stale_topology += rhs.movement_failures.stale_topology;
517}
518
519template <typename World, typename ClassOrTag>
521[[nodiscard]] auto process_unit_path_agents(
522 const World& world, std::span<PathAgentState> agents,
523 PathRequestRuntime& runtime, PathRuntimeCachePolicy policy = {},
525 PathSubmitScope scope = PathSubmitScope::All,
526 PathAgentRoutes* routes = nullptr) -> PathAgentFrameStats {
527 auto stats = submit_path_agents(agents, runtime, scope);
528 (void)runtime.template process_unit_cached<World, ClassOrTag>(world, policy,
529 graph);
530 add_path_agent_stats(
531 stats, apply_path_agent_results(agents, runtime, scope, routes));
532 stats.precheck_ruled_out = runtime.stats().precheck_ruled_out;
533 return stats;
534}
535
536template <typename World, typename Class, std::uint32_t MaxCost>
538[[nodiscard]] auto process_weighted_path_agents(
539 const World& world, std::span<PathAgentState> agents,
540 PathRequestRuntime& runtime, PathRuntimeCachePolicy policy = {},
542 PathSubmitScope scope = PathSubmitScope::All,
543 PathAgentRoutes* routes = nullptr) -> PathAgentFrameStats {
544 auto stats = submit_path_agents(agents, runtime, scope);
545 (void)runtime.template process_weighted_batch<World, Class, MaxCost>(
546 world, policy, graph);
547 add_path_agent_stats(
548 stats, apply_path_agent_results(agents, runtime, scope, routes));
549 stats.precheck_ruled_out = runtime.stats().precheck_ruled_out;
550 return stats;
551}
552
553template <typename World, typename PassableTag, typename CostTag,
554 std::uint32_t MaxCost>
556[[nodiscard]] auto process_weighted_path_agents(
557 const World& world, std::span<PathAgentState> agents,
558 PathRequestRuntime& runtime, PathRuntimeCachePolicy policy = {},
560 PathSubmitScope scope = PathSubmitScope::All,
561 PathAgentRoutes* routes = nullptr) -> PathAgentFrameStats {
562 auto stats = submit_path_agents(agents, runtime, scope);
563 (void)runtime
564 .template process_weighted_batch<World, PassableTag, CostTag, MaxCost>(
565 world, policy, graph);
566 add_path_agent_stats(
567 stats, apply_path_agent_results(agents, runtime, scope, routes));
568 stats.precheck_ruled_out = runtime.stats().precheck_ruled_out;
569 return stats;
570}
571
572} // namespace tess
Definition path_runtime.h:92
Region graph storage specialized by dense or sparse residency policy.
Definition topology.h:309
Definition world.h:22
Definition shape.h:30
Aggregates rejected movement attempts by retry-relevant category.
Definition movement.h:53
Describes an adjacent move and any versions it expects to remain current.
Definition movement.h:39
Summarizes path submission, results, movement, and failure outcomes.
Definition path_agent.h:44
Owns index-paired route copies retained across scoped processing passes.
Definition path_agent.h:88
Stores one agent's goal, route cursor, and retry lifecycle state.
Definition path_agent.h:32
Specifies inclusive start and goal coordinates for a path query.
Definition path.h:54
Definition path_runtime.h:32
Definition path_runtime.h:26