tess 1.0.0
Performance-first tile and path simulation substrate
Loading...
Searching...
No Matches
pibt_movement.h
1#pragma once
2
3// PIBT movement tier: priority inheritance with backtracking, composed with
4// the joint commit's swap policy. The joint advance only admits moves along
5// retained routes, so an agent whose route is blocked never considers
6// stepping aside, and wedges whose resolution requires yielding onto an
7// off-route tile persist regardless of retry patience. PIBT closes that
8// gap: each agent ranks staying put and every legal neighbour, the
9// highest-priority agent decides first, an agent whose chosen tile is held
10// by an undecided peer lends that peer its priority so the peer decides —
11// and possibly yields off its route — immediately, and a peer that cannot
12// place anywhere backtracks the chooser to its next candidate.
13//
14// The gate evidence scoping this tier (optimization log, "Phase 3 Gate
15// Re-Evaluation"): on thin cycle-rich maps the dominant stranding cause is
16// sealing — settled arrivals cutting a live agent's goal off — which no
17// movement tier can resolve; goal placement owns that hazard. PIBT's
18// measured edge is live congestion: it eliminates most
19// stranded-but-reachable residuals, resolves dead-end yields under
20// `Forbid`, and keeps populations moving so fewer seals form.
21//
22// Two contracts carry the tier's correctness:
23//
24// - **The ranking oracle must share the agent's movement-class
25// passability.** A terrain-only oracle under a settled-aware class rates
26// standing beside an obstruction above any detour, and the agent parks
27// there forever. This failure mode is proven in the tier's tests.
28// - **Priorities must be adaptive** — incremented while an agent is
29// unarrived and reset on arrival — or agents can starve. PIBT's
30// reachability guarantee (every agent reaches its goal in finite time on
31// graphs whose adjacent vertices share a cycle of length >= 3) depends on
32// this rule.
33//
34// Like the distance-field product family, this tier requires an
35// AlwaysResidentWorld because its ranking product indexes the full tile space.
36
37#include <tess/core/shape.h>
38#include <tess/sim/joint_movement.h>
39#include <tess/sim/movement.h>
40#include <tess/sim/path_agent.h>
41#include <tess/sim/path_agent_tick.h>
42
43#include <concepts>
44#include <cstddef>
45#include <cstdint>
46#include <cstdlib>
47#include <limits>
48#include <span>
49#include <type_traits>
50#include <vector>
51
52namespace tess {
53
54namespace detail {
55
56inline constexpr std::size_t kPibtMaxCandidates = 16;
57
58// One pending decision in an inheritance chain. Chains are bounded only by
59// the agent count, so frames live in caller-owned heap storage rather than
60// on the process stack.
61struct PibtFrame {
62 struct Candidate {
63 Coord3 coord{};
64 std::uint32_t rank_value = 0;
65 };
66 std::uint32_t agent = 0;
67 std::uint32_t next_candidate = 0;
68 std::uint32_t candidate_count = 0;
69 bool waiting = false;
70 Candidate candidates[kPibtMaxCandidates] = {};
71};
72
73struct PibtPrioritiesAccess;
74
75// The round-local half of `PibtPriorities`. `elapsed` is the caller's knob
76// and stays public; the decision order and the inheritance stack are rebuilt
77// from scratch every pass, and `frames` was also the last public member typed
78// with a `detail` struct — promoting `PibtFrame` would have frozen an
79// implementation layout, so the member moves out of sight instead.
80struct PibtScratchState {
81 std::vector<std::uint32_t> order;
82 std::vector<PibtFrame> frames;
83
84 void reserve(std::size_t agent_count) {
85 order.reserve(agent_count);
86 frames.reserve(agent_count);
87 }
88};
89
90} // namespace detail
91
92// Public because it constrains public entry points. A caller whose ranking
93// callable does not satisfy this gets the constraint named in the error,
94// and previously could not name the thing it had to satisfy: the concept
95// deciding whether their lambda is accepted lived in `detail`, which
96// docs/style.md says carries no source-compatibility guarantee.
98template <typename Ranking>
99concept PibtRanking = requires(Ranking& rank, std::size_t agent, Coord3 coord) {
100 { rank(agent, coord) } -> std::convertible_to<std::uint32_t>;
101};
102
103// A production ranking oracle for passable worlds with walls. The oracle
104// contract at the top of this header names the two hazards a ranking must
105// avoid; this adds the third one measured in the mixed-colony bench:
106// distance heuristics that ignore terrain (Manhattan, or any field
107// truncated short of the map's doorways) rate wall-adjacent tiles best and
108// park agents at local minima that yields alone cannot fix. Each agent
109// already carries an exact, terrain-aware plan — its retained A* route —
110// so the oracle scores a candidate by its best LOCAL attachment to that
111// route: the hop onto a route point within `attach_radius`, plus that
112// point's remaining route length. The radius bound is load-bearing twice
113// over. First, distant attachments are wall-blind: far-side route points
114// lure agents onto a wall face (measured; the regression test pins it).
115// Second, the default radius of 1 is the only radius that is
116// passability-safe without inspecting terrain: distance-1 tile pairs are
117// edge-adjacent, so two passable tiles at distance 1 are mutually
118// reachable in one step, while distance-2 pairs can sit on opposite sides
119// of a one-tile wall — exactly the lure again, one tile closer. PIBT only
120// ever ranks an agent's own tile and its legal neighbours, so radius 1
121// covers route-following and one-tile yields; deeper displacement falls
122// through to the steer-back band, which is monotone toward the route.
123// A candidate with no local attachment scores far above any attached one,
124// graded by its distance to the nearest route point so displaced agents
125// steer back to the corridor. Agents with no usable route (fewer than two
126// points, or no goal) fall back to distance toward the goal, which is
127// also the natural passable-terrain behavior. All distances are the
128// overflow-safe three-axis Manhattan metric clamped into the score
129// domain, so stacked 3D worlds rank levels apart as apart.
130//
131// Complexity: O(remaining route) per candidate query, bounded by the
132// route lengths the planner produces. The scan starts at the agent's
133// route cursor but the cursor does not advance during off-route PIBT
134// walks, so callers should treat the full-route scan as the cost model.
138 std::span<const PathAgentState> agents;
139 const PathAgentRoutes* routes = nullptr;
143 std::uint32_t attach_radius = 1;
144
146 static constexpr std::uint32_t kDetachedBase =
147 std::numeric_limits<std::uint32_t>::max() / 8;
148
149 [[nodiscard]] static auto clamped_distance(Coord3 lhs, Coord3 rhs) noexcept
150 -> std::uint32_t {
151 const std::uint64_t distance = manhattan_distance(lhs, rhs);
152 return distance >= kDetachedBase ? kDetachedBase - 1
153 : static_cast<std::uint32_t>(distance);
154 }
155
156 [[nodiscard]] auto operator()(std::size_t agent, Coord3 candidate) const
157 -> std::uint32_t {
158 const PathAgentState& state = agents[agent];
159 if (routes == nullptr || agent >= routes->routes.size()) {
160 return clamped_distance(candidate,
161 state.has_goal ? state.goal : state.position);
162 }
163 const std::vector<Coord3>& route = routes->routes[agent];
164 if (!state.has_goal || route.size() < 2) {
165 return clamped_distance(candidate,
166 state.has_goal ? state.goal : state.position);
167 }
168 std::uint32_t best = kDetachedBase;
169 std::uint32_t nearest = kDetachedBase;
170 for (std::size_t j = state.path_index; j < route.size(); ++j) {
171 const std::uint32_t attach = clamped_distance(candidate, route[j]);
172 nearest = std::min(nearest, attach);
173 if (attach > attach_radius) {
174 continue;
175 }
176 const std::uint32_t remaining =
177 static_cast<std::uint32_t>(route.size() - 1 - j);
178 best = std::min(best, attach + remaining);
179 }
180 if (best == kDetachedBase) {
181 return kDetachedBase + nearest;
182 }
183 return best;
184 }
185};
186
187// Index-paired with the agent span handed to the advance, exactly like
188// `PathAgentRoutes`: a caller that reorders, removes, or compacts its agents
189// between ticks must reset this state or keep it in sync itself.
193 std::vector<std::uint32_t> elapsed;
194
196 void reserve(std::size_t agent_count) {
197 elapsed.reserve(agent_count);
198 scratch_.reserve(agent_count);
199 }
200
201 private:
202 friend struct detail::PibtPrioritiesAccess;
203
204 detail::PibtScratchState scratch_;
205};
206
207namespace detail {
208
209// Matches the joint scratch's door, deliberately: this type could befriend
210// the advance below directly, since both live in this header, but then two
211// adjacent scratch types would hide their state by two different mechanisms
212// and a caller reading one would learn nothing about the other. The same
213// caveat applies — `detail` is the boundary, not unreachability.
214struct PibtPrioritiesAccess {
215 [[nodiscard]] static auto scratch(PibtPriorities& priorities) noexcept
216 -> PibtScratchState& {
217 return priorities.scratch_;
218 }
219};
220
221} // namespace detail
222
223// One decision pass per step (`max_steps` passes, zero meaning paused as in
224// the joint advance; a pass that moves nobody ends the call early since it
225// would repeat identically):
226// 1. Adaptive priorities update: unarrived agents' `elapsed` increments,
227// arrived agents reset to zero; decision order is elapsed descending
228// with span index as the deterministic tie-break. An agent standing on
229// a tile its class cannot pass fails `ImpassableFrom` without deciding,
230// exactly as `commit_movement_intent` does.
231// 2. Each undecided agent, in that order, considers staying put plus every
232// legal transition of its movement class (enumerated through the
233// resolved transition model, so hex and diagonal lattices are handled),
234// skipping reserved tiles and tiles occupied by anything outside the
235// agent span, ranked by the caller's oracle (lower is better;
236// enumeration order breaks ties).
237// 3. Vertex conflicts skip the candidate. Edge conflicts (the candidate's
238// occupant has already decided to enter this agent's tile) follow
239// `SwapPolicy`, sharing `JointMoveOptions` with the joint advance.
240// 4. A candidate held by an undecided peer is claimed tentatively and the
241// peer inherits the decision turn; if the peer cannot place anywhere,
242// the next candidate is tried (backtracking) while the claim stays with
243// the failed peer, protecting its tile from later deciders. An agent
244// with no placeable candidate keeps its tile.
245// 5. The decided configuration applies as a set with the joint commit's
246// semantics: sources clear before destinations set, reservations clear
247// on entry, dirty marks match `commit_movement_intent`, and observer
248// callbacks fire only after the whole configuration is applied. A move
249// off the retained route drops the route (`NoPath`) so scoped
250// resubmission replans; an agent that wanted to move and could not
251// records an `Occupied` block (or `ImpassableFrom` for an impassable
252// source) with the usual retry semantics.
254template <typename World, typename ClassOrTag, typename OccupancyTag,
255 typename ReservationTag, typename Ranking, typename OnCommit>
256 requires PibtRanking<Ranking> &&
257 std::invocable<OnCommit&, std::size_t, Coord3, Coord3>
258auto advance_path_agents_with_pibt(
259 World& world, std::span<PathAgentState> agents,
260 const PathAgentRoutes& routes, PibtPriorities& priorities,
261 JointMoveScratch& scratch_storage, Ranking&& rank, JointMoveOptions options,
262 PathAgentAdvanceOptions advance_options, OnCommit&& on_commit,
263 diagnostics::FlowAccounting* accounting = nullptr) -> JointMoveStats {
264 using Shape = typename World::shape_type;
265 using Class = movement::movement_class_of<ClassOrTag>;
267 static_assert(
268 std::is_same_v<typename World::residency_type, AlwaysResident>,
269 "advance_path_agents_with_pibt requires an AlwaysResidentWorld; use "
270 "another movement tier for sparse worlds.");
271 TESS_ASSERT(routes.routes.size() >= agents.size());
272 auto& scratch = detail::JointMoveScratchAccess::state(scratch_storage);
273 auto& decision = detail::PibtPrioritiesAccess::scratch(priorities);
274 const auto model = Model{AdjacentTransitions{}};
275 JointMoveStats stats;
276 if (advance_options.max_steps == 0) {
277 return stats; // paused movement, as in the joint advance
278 }
279 const auto n = agents.size();
280 const auto none = static_cast<std::uint32_t>(-1);
281 constexpr std::uint8_t undecided = 0;
282 constexpr std::uint8_t deciding = 1;
283 constexpr std::uint8_t decided = 2;
284
285 for (std::size_t pass = 0; pass < advance_options.max_steps; ++pass) {
286 // 1: adaptive priorities and decision order.
287 if (priorities.elapsed.size() < n) {
288 priorities.elapsed.resize(n, 0);
289 }
290 for (std::size_t i = 0; i < n; ++i) {
291 const auto active =
292 agents[i].has_goal && agents[i].phase != PathAgentPhase::Unreachable;
293 auto& elapsed = priorities.elapsed[i];
294 elapsed = active ? (elapsed == std::numeric_limits<std::uint32_t>::max()
295 ? elapsed
296 : elapsed + 1)
297 : 0;
298 }
299 decision.order.resize(n);
300 for (std::size_t i = 0; i < n; ++i) {
301 decision.order[i] = static_cast<std::uint32_t>(i);
302 }
303 // Insertion sort keeps the warm path allocation-free (std::stable_sort may
304 // allocate a temporary buffer) and is stable, so span index breaks ties.
305 for (std::size_t i = 1; i < n; ++i) {
306 const auto value = decision.order[i];
307 std::size_t j = i;
308 while (j > 0 && priorities.elapsed[decision.order[j - 1]] <
309 priorities.elapsed[value]) {
310 decision.order[j] = decision.order[j - 1];
311 --j;
312 }
313 decision.order[j] = value;
314 }
315
316 // Occupant index over every agent (movers or not), as in the joint pass.
317 scratch.desired.assign(n, Coord3{});
318 scratch.state.assign(n, undecided);
319 scratch.failure.assign(n, static_cast<std::uint8_t>(MovementStatus::Moved));
320 scratch.claimed.clear();
321 scratch.committed.clear();
322 scratch.committed_from.clear();
323 scratch.occupant_key.clear();
324 scratch.occupant_agent.clear();
325 for (std::size_t i = 0; i < n; ++i) {
326 scratch.desired[i] = agents[i].position;
327 scratch.occupant_key.push_back(tile_key<Shape>(agents[i].position).value);
328 scratch.occupant_agent.push_back(static_cast<std::uint32_t>(i));
329 }
330 {
331 auto& keys = scratch.occupant_key;
332 auto& vals = scratch.occupant_agent;
333 for (std::size_t i = 1; i < keys.size(); ++i) {
334 auto key = keys[i];
335 auto val = vals[i];
336 std::size_t j = i;
337 while (j > 0 && keys[j - 1] > key) {
338 keys[j] = keys[j - 1];
339 vals[j] = vals[j - 1];
340 --j;
341 }
342 keys[j] = key;
343 vals[j] = val;
344 }
345 }
346
347 const auto find_occupant = [&](Coord3 coord) -> std::uint32_t {
348 return detail::joint_find_occupant(scratch, tile_key<Shape>(coord).value);
349 };
350 const auto claim = [&](Coord3 coord) -> bool {
351 return detail::joint_claim(scratch, tile_key<Shape>(coord).value);
352 };
353
354 // 2-4: the decision machine. Decisions cannot share one scratch candidate
355 // buffer (an inheritance chain holds every participant's ranked
356 // candidates at once), and chain length is bounded only by the agent
357 // count, so each participant gets a fixed-size frame on the caller-owned
358 // `PibtPriorities` stack — never the process stack.
359 auto& frames = decision.frames;
360 frames.clear();
361
362 // Starts agent `i` deciding: either pushes its frame or fails
363 // immediately. An impassable source cannot be vacated, exactly as
364 // `commit_movement_intent` fails `ImpassableFrom`; the agent keeps its tile
365 // and an inheriting caller must backtrack.
366 //
367 // An agent with no goal, or one whose lifecycle already ended at
368 // `Unreachable`, cannot be vacated either. Only inheritance reaches such
369 // an agent: the priority loop skips them, and the apply pass tests the
370 // same condition before touching a stay-put agent. Reached through
371 // inheritance they would be shoved off their tile by passing traffic and
372 // rewritten to `Blocked`, restarting a terminal lifecycle. Treat them the
373 // way an impassable source is treated -- claim the tile so later deciders
374 // are vertex-rejected rather than stacking on it, and make the inheriting
375 // parent backtrack.
376 const auto start_deciding = [&](std::size_t i) -> bool {
377 scratch.state[i] = deciding;
378 const auto position = agents[i].position;
379 if (!agents[i].has_goal ||
380 agents[i].phase == PathAgentPhase::Unreachable) {
381 (void)claim(position);
382 scratch.failure[i] =
383 static_cast<std::uint8_t>(MovementStatus::Occupied);
384 scratch.state[i] = decided;
385 return false;
386 }
387 if (!detail::is_passable<World, ClassOrTag>(world, position)) {
388 (void)claim(position);
389 scratch.failure[i] =
390 static_cast<std::uint8_t>(MovementStatus::ImpassableFrom);
391 scratch.state[i] = decided;
392 return false;
393 }
394 frames.emplace_back();
395 auto& frame = frames.back();
396 frame.agent = static_cast<std::uint32_t>(i);
397 const auto index = detail::tile_index<Shape>(position);
398 model.for_each_forward(world, position, index, [&](auto probe) {
399 if (probe.availability != TransitionAvailability::Legal ||
400 probe.cost_overflow ||
401 frame.candidate_count >= detail::kPibtMaxCandidates - 1) {
402 return;
403 }
404 const auto coord = detail::tile_coord<Shape>(probe.to_index);
405 if (!detail::is_passable<World, ClassOrTag>(world, coord)) {
406 return;
407 }
408 if (world.template field<ReservationTag>(coord)) {
409 return; // application-owned do-not-enter, as in the joint pass
410 }
411 if (world.template field<OccupancyTag>(coord) &&
412 find_occupant(coord) == none) {
413 return; // occupied by something outside this span, as in the joint
414 // pass's external-occupant rejection
415 }
416 frame.candidates[frame.candidate_count++] = {coord, rank(i, coord)};
417 });
418 frame.candidates[frame.candidate_count++] = {position, rank(i, position)};
419 // Insertion sort: allocation-free and stable, so the model's
420 // enumeration order breaks ranking ties deterministically.
421 for (std::uint32_t a = 1; a < frame.candidate_count; ++a) {
422 const auto value = frame.candidates[a];
423 std::uint32_t b = a;
424 while (b > 0 && frame.candidates[b - 1].rank_value > value.rank_value) {
425 frame.candidates[b] = frame.candidates[b - 1];
426 --b;
427 }
428 frame.candidates[b] = value;
429 }
430 return true;
431 };
432
433 const auto run_decision = [&](std::size_t root) {
434 if (!start_deciding(root)) {
435 return;
436 }
437 bool child_succeeded = false;
438 while (!frames.empty()) {
439 auto& frame = frames.back();
440 const auto i = static_cast<std::size_t>(frame.agent);
441 const auto position = agents[i].position;
442 if (frame.waiting) {
443 frame.waiting = false;
444 if (child_succeeded) {
445 scratch.state[i] = decided;
446 frames.pop_back();
447 child_succeeded = true;
448 continue;
449 }
450 // The inherited peer failed and stays on its tile; its claim must
451 // survive to protect it from later deciders (pypibt re-marks
452 // `occupied_nxt` with the failed agent), so only this agent's
453 // tentative desire is undone before trying the next candidate.
454 scratch.desired[i] = position;
455 ++frame.next_candidate;
456 }
457 if (frame.next_candidate >= frame.candidate_count) {
458 // Nowhere to place: keep the tile so inheritance chains cannot
459 // displace this agent, and report failure to the inheriting caller.
460 (void)claim(position);
461 scratch.desired[i] = position;
462 scratch.state[i] = decided;
463 frames.pop_back();
464 child_succeeded = false;
465 continue;
466 }
467 const auto v = frame.candidates[frame.next_candidate].coord;
468 const auto occupant = find_occupant(v);
469 const bool moving = !(v == position);
470 // Edge conflict: the occupant of `v` is entering this agent's tile.
471 // `desired` starts at each agent's own position, so equality with
472 // this agent's position means the occupant actively chose it —
473 // whether it is fully decided or is the inheritance parent further
474 // down the stack (the parent writes `desired` before its peer
475 // decides, as pypibt sets `Q_to`).
476 bool is_swap = false;
477 if (moving && occupant != none &&
478 scratch.desired[occupant] == position) {
479 bool allow = false;
480 switch (options.swap_policy) {
481 case SwapPolicy::Permit:
482 allow = true;
483 break;
484 case SwapPolicy::PermitOnDeadlock:
485 allow =
486 agents[i].blocked_retries >= options.deadlock_ticks &&
487 agents[occupant].blocked_retries >= options.deadlock_ticks;
488 break;
489 case SwapPolicy::Forbid:
490 allow = false;
491 break;
492 }
493 if (!allow) {
494 ++stats.swaps_denied;
495 ++frame.next_candidate;
496 continue;
497 }
498 is_swap = true;
499 }
500 if (!claim(v)) {
501 ++frame.next_candidate;
502 continue; // vertex conflict
503 }
504 if (is_swap) {
505 // Counted only once the exchange is actually secured: a
506 // policy-allowed swap can still lose its destination to an earlier
507 // claim.
508 ++stats.swaps;
509 }
510 scratch.desired[i] = v;
511 if (moving && occupant != none &&
512 scratch.state[occupant] == undecided) {
513 // Priority inheritance: the occupant decides next with this
514 // agent's turn. `frame` may be invalidated by the push, so the
515 // waiting flag is set first.
516 frame.waiting = true;
517 if (!start_deciding(static_cast<std::size_t>(occupant))) {
518 child_succeeded = false; // resolved inline; waiting handles it
519 }
520 continue;
521 }
522 scratch.state[i] = decided;
523 frames.pop_back();
524 child_succeeded = true;
525 }
526 };
527
528 for (const auto i : decision.order) {
529 const auto agent_index = static_cast<std::size_t>(i);
530 if (scratch.state[agent_index] != undecided) {
531 continue;
532 }
533 if (!agents[agent_index].has_goal ||
534 agents[agent_index].phase == PathAgentPhase::Unreachable) {
535 scratch.state[agent_index] = decided;
536 continue;
537 }
538 run_decision(agent_index);
539 }
540
541 // 5: apply the configuration with the joint commit's semantics.
542 const auto advanced_before = stats.frame.advanced;
543 for (std::size_t i = 0; i < n; ++i) {
544 if (!(scratch.desired[i] == agents[i].position)) {
545 world.template field<OccupancyTag>(agents[i].position) = false;
546 }
547 }
548 for (std::size_t i = 0; i < n; ++i) {
549 auto& agent = agents[i];
550 const auto from = agent.position;
551 const auto to = scratch.desired[i];
552 if (to == from) {
553 if (agent.has_goal && agent.phase != PathAgentPhase::Unreachable) {
554 const auto status =
555 scratch.failure[i] ==
556 static_cast<std::uint8_t>(MovementStatus::Moved)
557 ? MovementStatus::Occupied
558 : static_cast<MovementStatus>(scratch.failure[i]);
559 record_movement_failure(stats.frame.movement_failures, status);
560 detail::block_path_agent(agent, status);
561 ++stats.frame.blocked_waits;
562 }
563 continue;
564 }
565 world.template field<OccupancyTag>(to) = true;
566 world.template field<ReservationTag>(to) = false;
567 if (advance_options.movement_dirty_mask) {
568 world.mark_dirty(chunk_key<Shape>(chunk_coord<Shape>(from)),
569 advance_options.movement_dirty_mask,
570 Box3{from, Extent3{1, 1, 1}});
571 world.mark_dirty(chunk_key<Shape>(chunk_coord<Shape>(to)),
572 advance_options.movement_dirty_mask,
573 Box3{to, Extent3{1, 1, 1}});
574 }
575 const auto& route = routes.routes[i];
576 const bool on_route =
577 detail::has_next_step(agent.path_index, route.size()) &&
578 route[agent.path_index + 1] == to;
579 agent.position = to;
580 if (on_route) {
581 ++agent.path_index;
582 detail::resume_path_agent(agent);
583 } else {
584 // Off the retained route: drop it so scoped resubmission replans from
585 // the new position. A Blocked agent with no last result is the state
586 // the tick drivers already treat as "needs planning".
587 agent.last_result.reset();
588 agent.phase = PathAgentPhase::Blocked;
589 agent.blocked_retries = 0;
590 }
591 scratch.committed.push_back(static_cast<std::uint32_t>(i));
592 scratch.committed_from.push_back(from);
593 ++stats.frame.advanced;
594 // `has_goal` gates the comparison because `clear_path_agent_goal`
595 // zeroes `goal`, so a goalless agent standing on the origin tile would
596 // otherwise register an arrival for a journey that was never admitted
597 // -- inflating `completed` and breaking the retention identity. Every
598 // other arrival site reaches this check behind the same gate.
599 if (agent.has_goal && agent.position == agent.goal) {
600 arrive_path_agent(agent, accounting);
601 agent.last_result = PathStatus::Found;
602 // Reset priority at the commit itself: a caller may assign a new
603 // goal before the next pass, and the journey it just finished must
604 // not carry its accumulated priority into the new one.
605 priorities.elapsed[i] = 0;
606 ++stats.frame.arrived;
607 }
608 }
609 // Same observer contract as the joint advance: callbacks observe the fully
610 // applied configuration; an injective tile mirror must buffer the batch.
611 for (std::size_t k = 0; k < scratch.committed.size(); ++k) {
612 const auto index = static_cast<std::size_t>(scratch.committed[k]);
613 on_commit(index, scratch.committed_from[k], agents[index].position);
614 }
615 // A pass that moved nobody would repeat identically (a uniform elapsed
616 // increment cannot reorder decisions), so further passes are pure waste.
617 if (stats.frame.advanced == advanced_before) {
618 break;
619 }
620 }
621 return stats;
622}
623
625template <typename World, typename ClassOrTag, typename OccupancyTag,
626 typename ReservationTag, typename Ranking>
627 requires PibtRanking<Ranking>
628auto advance_path_agents_with_pibt(
629 World& world, std::span<PathAgentState> agents,
630 const PathAgentRoutes& routes, PibtPriorities& priorities,
631 JointMoveScratch& scratch, Ranking&& rank, JointMoveOptions options = {},
632 PathAgentAdvanceOptions advance_options = {},
633 diagnostics::FlowAccounting* accounting = nullptr) -> JointMoveStats {
634 return advance_path_agents_with_pibt<World, ClassOrTag, OccupancyTag,
635 ReservationTag>(
636 world, agents, routes, priorities, scratch, std::forward<Ranking>(rank),
637 options, advance_options, [](std::size_t, Coord3, Coord3) {}, accounting);
638}
639
640// Mirrors `tick_weighted_path_agents_with_joint_movement` with the PIBT
641// advance in place of the joint one; planning semantics are identical.
643template <typename World, typename Class, std::uint32_t MaxCost,
644 typename OccupancyTag, typename ReservationTag, typename Ranking>
645 requires PibtRanking<Ranking>
646[[nodiscard]] auto tick_weighted_path_agents_with_pibt(
647 PathAgentTickState& state, World& world, std::span<PathAgentState> agents,
648 PathRequestRuntime& runtime, PibtPriorities& priorities,
649 JointMoveScratch& scratch, Ranking&& rank,
650 PathAgentTickOptions options = {}, JointMoveOptions pibt_options = {},
651 const RegionGraphT<typename World::residency_type>* graph = nullptr,
652 JointMoveStats* pibt_stats = nullptr) -> PathAgentTickStats {
653 PathAgentTickStats stats;
654 stats.tick = advance_sim_tick(state.clock);
655
656 const bool repath_needed = prepare_path_agent_processing(
657 agents, options, stats, state.flow_accounting);
658 state.routes.ensure_size(agents.size());
659 if (state.pathing_dirty || repath_needed) {
660 const auto scope =
661 state.pathing_dirty ? PathSubmitScope::All : PathSubmitScope::NeedsOnly;
662 stats.pathing = process_weighted_path_agents<World, Class, MaxCost>(
663 world, agents, runtime, options.cache_policy, graph, scope,
664 &state.routes, state.flow_accounting);
665 stats.processed_paths = true;
666 state.pathing_dirty = false;
667 }
668
669 auto moved =
670 advance_path_agents_with_pibt<World, Class, OccupancyTag, ReservationTag>(
671 world, agents, state.routes, priorities, scratch,
672 std::forward<Ranking>(rank), pibt_options,
673 PathAgentAdvanceOptions{options.max_steps,
674 options.movement_dirty_mask},
675 state.flow_accounting);
676 stats.movement = moved.frame;
677 if (pibt_stats != nullptr) {
678 *pibt_stats = moved;
679 }
680 return stats;
681}
682
683} // namespace tess
Definition transition_model.h:380
Definition world.h:22
Requirements on a ranking callable supplied to the PIBT movement tier.
Definition pibt_movement.h:99
Supplies no special transitions beyond ordinary face adjacency.
Definition transition_provider.h:132
Definition shape.h:94
Definition shape.h:46
Definition shape.h:14
Configures cycle admission for one joint movement pass.
Definition joint_movement.h:54
Caller-owned workspace for the joint movement pass.
Definition joint_movement.h:125
Reports joint-admission outcomes alongside the standard movement stats.
Definition joint_movement.h:66
Configures bounded direct movement and the dirty bits it emits.
Definition path_agent.h:78
Owns index-paired route copies retained across scoped processing passes.
Definition path_agent.h:115
Stores one agent's goal, route cursor, and retry lifecycle state.
Definition path_agent.h:35
Caller-owned adaptive priorities for the PIBT movement tier.
Definition pibt_movement.h:191
std::vector< std::uint32_t > elapsed
Ticks each agent has spent unarrived; higher decides earlier.
Definition pibt_movement.h:193
void reserve(std::size_t agent_count)
Pre-sizes the containers for agent_count agents.
Definition pibt_movement.h:196
Definition pibt_movement.h:137
std::uint32_t attach_radius
Definition pibt_movement.h:143
static constexpr std::uint32_t kDetachedBase
Scores below kDetachedBase are attached; higher steer back.
Definition pibt_movement.h:146
Definition shape.h:296
Definition diagnostics.h:505