tess 1.0.0
Performance-first tile and path simulation substrate
Loading...
Searching...
No Matches
field_product_cache.h
1#pragma once
2
3#include <tess/core/tag_identity.h>
4#include <tess/path/path.h>
5
6#include <algorithm>
7#include <concepts>
8#include <cstddef>
9#include <cstdint>
10#include <limits>
11#include <memory>
12#include <span>
13#include <utility>
14#include <vector>
15
16namespace tess {
17
19class GoalSet {
20 public:
21 void reserve(std::size_t count) { goals_.reserve(count); }
22
23 void clear() noexcept { goals_.clear(); }
24
25 void add(Coord3 goal) { goals_.push_back(goal); }
26
27 [[nodiscard]] auto empty() const noexcept -> bool { return goals_.empty(); }
28
29 [[nodiscard]] auto size() const noexcept -> std::size_t {
30 return goals_.size();
31 }
32
33 [[nodiscard]] auto goals() const noexcept -> std::span<const Coord3> {
34 return goals_;
35 }
36
37 private:
38 std::vector<Coord3> goals_;
39};
40
43 PathStatus status = PathStatus::NotComputed;
44 std::uint32_t cost = 0;
45 Coord3 target{};
46 std::size_t expanded_nodes = 0;
47 std::size_t reached_nodes = 0;
48 std::span<const Coord3> path;
49 std::uint32_t cost_scale = 1;
50};
51
61 public:
62 void reserve_goals(std::size_t count) { goals_.reserve(count); }
63
64 void reserve_nodes(std::size_t node_count) { distance_.reserve(node_count); }
65
66 // Dependency sets are bounded by the world's chunk count (failure
67 // products capture every chunk): reserve chunk_count to keep steady-state
68 // rebuilds allocation-free.
69 void reserve_dependencies(std::size_t count) { dependencies_.reserve(count); }
70
71 void clear() noexcept {
72 status_ = PathStatus::NotComputed;
73 expanded_nodes_ = 0;
74 reached_nodes_ = 0;
75 tile_count_ = 0;
76 chunk_count_ = 0;
77 local_tile_count_ = 0;
78 model_class_identity_ = 0;
79 model_lattice_identity_ = 0;
80 model_lattice_version_ = 0;
81 model_step_identity_ = 0;
82 model_cost_scale_ = 0;
83 model_provider_identity_ = 0;
84 model_provider_instance_identity_ = nullptr;
85 model_provider_revision_ = 0;
86 goals_.clear();
87 distance_.clear();
88 dependencies_.clear();
89 }
90
91 // See WeightedRouteProduct::is_valid: empty dependencies are invalid by
92 // definition so cleared/never-built products never replay vacuously.
93 template <typename World>
94 [[nodiscard]] auto is_valid(const World& world) const noexcept -> bool {
95 return !dependencies_.empty() && dependencies_.is_valid(world);
96 }
97
98 [[nodiscard]] auto status() const noexcept -> PathStatus { return status_; }
99
100 [[nodiscard]] auto goals() const noexcept -> std::span<const Coord3> {
101 return goals_;
102 }
103
104 [[nodiscard]] auto dependencies() const noexcept
105 -> std::span<const ContentVersionDependencies::ContentVersionDependency> {
106 return dependencies_.chunks();
107 }
108
109 [[nodiscard]] auto expanded_nodes() const noexcept -> std::size_t {
110 return expanded_nodes_;
111 }
112
113 [[nodiscard]] auto reached_nodes() const noexcept -> std::size_t {
114 return reached_nodes_;
115 }
116
118 static constexpr std::uint32_t unreachable_distance =
119 std::numeric_limits<std::uint32_t>::max();
120
121 // Freshness and model identity remain the caller's job, exactly as for the
122 // route cache: validate with `is_valid` after world edits and route full
123 // queries through the stamp-checked readers. This accessor guards only the
124 // O(1) shape-identity fields, because a per-tile oracle consulted several
125 // times per agent per tick cannot afford the readers' full validation.
127 template <typename World>
128 [[nodiscard]] auto distance_at(Coord3 coord) const noexcept -> std::uint32_t {
129 using Shape = typename World::shape_type;
130 if (status_ != PathStatus::Found ||
131 tile_count_ != detail::tile_count<World>() ||
132 chunk_count_ != World::chunk_count ||
133 local_tile_count_ != World::local_tile_count ||
134 shape_size_ != ShapeTraits<Shape>::size ||
135 chunk_extent_ != ShapeTraits<Shape>::chunk || !contains<Shape>(coord)) {
137 }
138 const auto index =
139 static_cast<std::size_t>(detail::tile_index<Shape>(coord));
140 if (index >= distance_.size()) {
142 }
143 return distance_[index];
144 }
145
146 [[nodiscard]] auto byte_size() const noexcept -> std::size_t {
147 return distance_.size() * sizeof(std::uint32_t) +
148 goals_.size() * sizeof(Coord3) +
149 dependencies_.size() *
151 }
152
153 private:
154 friend class FieldProductCache;
155
156 template <typename World, typename Tag>
157 friend auto build_distance_field_product(const World& world,
158 const GoalSet& goals,
159 DistanceFieldProduct& product,
160 DistanceFieldScratch& scratch)
161 -> DistanceFieldResult;
162
163 template <typename World, typename Tag, typename Provider>
164 friend auto build_distance_field_product(const World& world,
165 const GoalSet& goals,
166 DistanceFieldProduct& product,
167 DistanceFieldScratch& scratch,
168 const Provider& provider)
169 -> DistanceFieldResult;
170
171 template <typename World, typename Tag>
172 friend auto distance_field_product_path(const World& world, Coord3 start,
173 const DistanceFieldProduct& product,
174 DistanceFieldScratch& scratch)
175 -> PathResult;
176
177 template <typename World, typename Tag, typename Provider>
178 friend auto distance_field_product_path(const World& world, Coord3 start,
179 const DistanceFieldProduct& product,
180 DistanceFieldScratch& scratch,
181 const Provider& provider)
182 -> PathResult;
183
184 template <typename World, typename Tag>
185 friend auto nearest_target(const World& world, Coord3 start,
186 const DistanceFieldProduct& product,
187 DistanceFieldScratch& scratch)
188 -> NearestTargetResult;
189
190 template <typename World, typename Tag, typename Provider>
191 friend auto nearest_target(const World& world, Coord3 start,
192 const DistanceFieldProduct& product,
193 DistanceFieldScratch& scratch,
194 const Provider& provider) -> NearestTargetResult;
195
196 template <typename World, typename Class, typename Provider>
198 const World& world, const GoalSet& goals, DistanceFieldProduct& product,
199 DistanceFieldScratch& scratch, const Provider& provider)
200 -> DistanceFieldResult;
201
202 template <typename World, typename Class, typename Provider>
204 const World& world, Coord3 start, const DistanceFieldProduct& product,
205 DistanceFieldScratch& scratch, const Provider& provider) -> PathResult;
206
207 [[nodiscard]] auto is_goal(Coord3 coord) const noexcept -> bool {
208 for (const auto goal : goals_) {
209 if (goal == coord) {
210 return true;
211 }
212 }
213 return false;
214 }
215
216 PathStatus status_ = PathStatus::NotComputed;
217 std::size_t expanded_nodes_ = 0;
218 std::size_t reached_nodes_ = 0;
219 std::size_t tile_count_ = 0;
220 std::uint64_t chunk_count_ = 0;
221 std::uint64_t local_tile_count_ = 0;
222 Extent3 shape_size_{};
223 Extent3 chunk_extent_{};
224 std::uintptr_t model_class_identity_ = 0;
225 std::uint32_t model_lattice_identity_ = 0;
226 std::uint32_t model_lattice_version_ = 0;
227 std::uint32_t model_step_identity_ = 0;
228 std::uint32_t model_cost_scale_ = 0;
229 std::uintptr_t model_provider_identity_ = 0;
230 const void* model_provider_instance_identity_ = nullptr;
231 std::uint64_t model_provider_revision_ = 0;
232 std::vector<Coord3> goals_;
233 std::vector<std::uint32_t> distance_;
234 ContentVersionDependencies dependencies_;
235};
236
239 std::size_t entries = 0;
240 std::size_t bytes = 0;
241 std::size_t hits = 0;
242 std::size_t misses = 0;
243 std::size_t evictions = 0;
244 std::size_t stale_rejections = 0;
245};
246
247// Entries validate against world content versions, not a world instance:
248// keep one cache per world (see UnitRouteCache fingerprint notes for the
249// aliasing mechanism).
263class FieldProductCache {
264 struct Key {
265 std::uintptr_t movement_class = 0;
266 std::size_t tile_count = 0;
267 std::uint64_t chunk_count = 0;
268 std::uint64_t local_tile_count = 0;
269 Extent3 shape_size{};
270 Extent3 chunk_extent{};
271 std::uint32_t lattice_identity = 0;
272 std::uint32_t lattice_version = 0;
273 std::uint32_t step_identity = 0;
274 std::uint32_t cost_scale = 0;
275 std::uintptr_t provider_identity = 0;
276 const void* provider_instance_identity = nullptr;
277 std::uint64_t provider_revision = 0;
278 std::vector<Coord3> goals;
279 };
280
281 public:
282 explicit FieldProductCache(
283 std::size_t byte_budget =
284 std::numeric_limits<std::size_t>::max()) noexcept
285 : byte_budget_(byte_budget) {}
286
287 void set_byte_budget(std::size_t byte_budget) {
288 byte_budget_ = byte_budget;
289 evict_to_budget();
290 }
291
292 void reserve_entries(std::size_t count) { entries_.reserve(count); }
293
294 [[nodiscard]] auto can_fit_distance_storage(
295 std::size_t node_count) const noexcept -> bool {
296 // Every product owns at least one u32 label per node; keys, goals,
297 // dependencies, and object overhead only increase the final size.
298 return node_count <= byte_budget_ / sizeof(std::uint32_t);
299 }
300
301 void clear() noexcept {
302 entries_.clear();
303 bytes_ = 0;
304 }
305
306 void reset_stats() noexcept {
307 hits_ = 0;
308 misses_ = 0;
309 evictions_ = 0;
310 stale_rejections_ = 0;
311 }
312
313 [[nodiscard]] auto stats() const noexcept -> FieldProductCacheStats {
315 entries_.size(), bytes_, hits_, misses_, evictions_, stale_rejections_,
316 };
317 }
318
319 // The returned pointer targets heap storage that never moves while its
320 // entry remains cached. Any operation that replaces, evicts, or clears that
321 // entry invalidates the pointer; a store for another key can evict it.
322 // Rejected over-budget stores do not mutate existing entries. Stale products
323 // are erased on lookup and reported as rejections.
324 template <typename World, typename Tag>
325 [[nodiscard]] auto lookup(const World& world, const GoalSet& goals)
326 -> const DistanceFieldProduct* {
327 return lookup<World, Tag, AdjacentTransitions>(world, goals,
329 }
330
332 template <typename World, typename Tag, typename Provider>
333 [[nodiscard]] auto lookup(const World& world, const GoalSet& goals,
334 const Provider& provider)
335 -> const DistanceFieldProduct* {
336 for (std::size_t i = 0; i < entries_.size(); ++i) {
337 auto& entry = entries_[i];
338 if (!key_matches<World, Tag, Provider>(entry.key, goals.goals(),
339 provider)) {
340 continue;
341 }
342 if (!entry.product->is_valid(world)) {
343 ++stale_rejections_;
344 erase_entry(i);
345 return nullptr;
346 }
347 ++hits_;
348 entry.last_used = ++clock_;
349 return entry.product.get();
350 }
351
352 ++misses_;
353 return nullptr;
354 }
355
357 template <typename World, typename Class, typename Provider>
358 [[nodiscard]] auto lookup_weighted(const World& world, const GoalSet& goals,
359 const Provider& provider)
360 -> const DistanceFieldProduct* {
361 static_assert(std::derived_from<Class, movement::movement_class_tag>,
362 "FieldProductCache::lookup_weighted requires Class to be "
363 "a MovementClass");
364 for (std::size_t i = 0; i < entries_.size(); ++i) {
365 auto& entry = entries_[i];
366 if (!key_matches_class<World, Class, Provider>(entry.key, goals.goals(),
367 provider)) {
368 continue;
369 }
370 if (!entry.product->is_valid(world)) {
371 ++stale_rejections_;
372 erase_entry(i);
373 return nullptr;
374 }
375 ++hits_;
376 entry.last_used = ++clock_;
377 return entry.product.get();
378 }
379 ++misses_;
380 return nullptr;
381 }
382
384 template <typename World, typename Class>
385 [[nodiscard]] auto lookup_weighted(const World& world, const GoalSet& goals)
386 -> const DistanceFieldProduct* {
388 world, goals, AdjacentTransitions{});
389 }
390
391 // Takes ownership of `product` by move; world-sized field data is never
392 // copied. The argument is left EMPTY, but not necessarily with its own
393 // storage: where the store displaced an entry, the argument keeps that
394 // entry's buffers with the contents cleared, so a rebuild into it reuses
395 // the capacity. It is never left holding another key's data. A product
396 // whose entry exceeds the byte budget cannot be cached at all; that store
397 // preserves existing entries, leaves the argument untouched, and returns
398 // false.
399 template <typename World, typename Tag>
400 auto store(DistanceFieldProduct&& product) -> bool {
401 return store<World, Tag, AdjacentTransitions>(std::move(product),
403 }
404
405 // Shared by every store entry point: the product must have been built
406 // for exactly the model this key describes.
407 [[nodiscard]] static auto key_matches_product(
408 const Key& key, const DistanceFieldProduct& product) noexcept -> bool {
409 return product.model_class_identity_ == key.movement_class &&
410 product.tile_count_ == key.tile_count &&
411 product.chunk_count_ == key.chunk_count &&
412 product.local_tile_count_ == key.local_tile_count &&
413 product.model_lattice_identity_ == key.lattice_identity &&
414 product.model_lattice_version_ == key.lattice_version &&
415 product.model_step_identity_ == key.step_identity &&
416 product.model_cost_scale_ == key.cost_scale &&
417 product.model_provider_identity_ == key.provider_identity &&
418 product.model_provider_instance_identity_ ==
419 key.provider_instance_identity &&
420 product.model_provider_revision_ == key.provider_revision;
421 }
422
424 template <typename World, typename Tag, typename Provider>
425 auto store(DistanceFieldProduct&& product, const Provider& provider) -> bool {
426 if (product.status() != PathStatus::Found) {
427 return false;
428 }
429
430 auto key = make_key<World, Tag, Provider>(product.goals(), provider);
431 if (!key_matches_product(key, product)) {
432 return false;
433 }
434 return store_with_key(product, std::move(key)) != nullptr;
435 }
436
439 template <typename World, typename Tag, typename Provider>
440 auto store_reusing(DistanceFieldProduct& product, const Provider& provider)
441 -> const DistanceFieldProduct* {
442 if (product.status() != PathStatus::Found) {
443 return nullptr;
444 }
445 auto key = make_key<World, Tag, Provider>(product.goals(), provider);
446 if (!key_matches_product(key, product)) {
447 return nullptr;
448 }
449 return store_with_key(product, std::move(key));
450 }
451
453 template <typename World, typename Tag>
459
461 template <typename World, typename Class, typename Provider>
462 auto store_weighted(DistanceFieldProduct&& product, const Provider& provider)
463 -> bool {
464 static_assert(std::derived_from<Class, movement::movement_class_tag>,
465 "FieldProductCache::store_weighted requires Class to be a "
466 "MovementClass");
467 if (product.status() != PathStatus::Found) {
468 return false;
469 }
470 auto key =
471 make_key_for_class<World, Class, Provider>(product.goals(), provider);
472 if (!key_matches_product(key, product)) {
473 return false;
474 }
475 return store_with_key(product, std::move(key)) != nullptr;
476 }
477
480 template <typename World, typename Class, typename Provider>
482 const Provider& provider)
483 -> const DistanceFieldProduct* {
484 static_assert(
485 std::derived_from<Class, movement::movement_class_tag>,
486 "FieldProductCache::store_weighted_reusing requires Class to be a "
487 "MovementClass");
488 if (product.status() != PathStatus::Found) {
489 return nullptr;
490 }
491 auto key =
492 make_key_for_class<World, Class, Provider>(product.goals(), provider);
493 if (!key_matches_product(key, product)) {
494 return nullptr;
495 }
496 return store_with_key(product, std::move(key));
497 }
498
500 template <typename World, typename Class>
501 auto store_weighted(DistanceFieldProduct&& product) -> bool {
503 std::move(product), AdjacentTransitions{});
504 }
505
506 private:
507 // Takes `product` by reference and leaves it holding whatever storage
508 // the cache displaced -- the replaced entry's buffers, or an evicted
509 // one's. A caller that rebuilds into the same member therefore reuses
510 // capacity instead of reallocating a world-sized distance array on every
511 // build, and gets the stored pointer back without a second lookup (which
512 // would rescan every entry and count a spurious hit).
513 auto store_with_key(DistanceFieldProduct& product, Key key)
514 -> const DistanceFieldProduct* {
515 const auto bytes = entry_byte_size(key, product);
516 if (bytes > byte_budget_) {
517 return nullptr;
518 }
519
520 for (std::size_t i = 0; i < entries_.size(); ++i) {
521 if (keys_equal(entries_[i].key, key)) {
522 bytes_ -= entries_[i].bytes;
523 // Swap, not move-assign: the caller keeps the replaced buffers.
524 std::swap(*entries_[i].product, product);
525 entries_[i].last_used = ++clock_;
526 entries_[i].bytes = bytes;
527 bytes_ += bytes;
528 // Read the pointer BEFORE evicting. `evict_to_budget` erases from
529 // `entries_`, and erasing anything at an index below `i` shifts the
530 // vector, so `entries_[i]` afterwards is a different entry -- or
531 // past the end when `i` was last. The pointee itself is address-stable
532 // through the unique_ptr, so capturing it first is sufficient.
533 const auto* stored = entries_[i].product.get();
534 (void)evict_to_budget();
535 // The caller keeps the replaced entry's buffers, not its contents:
536 // clear() is noexcept and retains capacity in all three vectors, so
537 // reuse costs nothing while a stale wrong-goal product can never be
538 // observed as valid.
539 product.clear();
540 return stored;
541 }
542 }
543
544 auto owned_product =
545 std::make_unique<DistanceFieldProduct>(std::move(product));
546 auto candidate =
547 Entry{std::move(key), std::move(owned_product), clock_ + 1u, bytes};
548 // Construct every throwing component before this insertion commit. If
549 // allocating either the product or vector storage fails, no null/partial
550 // entry becomes visible to lookup and all cache statistics stay unchanged.
551 entries_.push_back(std::move(candidate));
552 ++clock_;
553 bytes_ += bytes;
554 const auto* stored = entries_.back().product.get();
555 // The entry just appended cannot itself be evicted here: a store larger
556 // than the whole budget was rejected above, and this entry carries the
557 // newest `last_used`, so it is the last candidate the loop would pick --
558 // and by the time it is the only survivor, `bytes_ == bytes` is within
559 // budget and the loop has already stopped.
560 if (auto recycled = evict_to_budget(); recycled != nullptr) {
561 // `product` was moved from, so it is empty; give it the evicted
562 // buffers and clear the contents that came with them.
563 product = std::move(*recycled);
564 product.clear();
565 }
566 return stored;
567 }
568 // Each product lives behind a `unique_ptr` so `entries_` growth and
569 // eviction of other entries never relocate a product that a caller still
570 // borrows through `lookup()`.
571 struct Entry {
572 Key key;
573 std::unique_ptr<DistanceFieldProduct> product;
574 std::uint64_t last_used = 0;
575 std::size_t bytes = 0;
576 };
577
578 template <typename World, typename Tag, typename Provider>
579 [[nodiscard]] static auto make_key(std::span<const Coord3> goals,
580 const Provider& provider) -> Key {
581 using Class = movement::movement_class_of<Tag>;
582 using UnitClass = movement::detail::UnitMovementClass<Class>;
583 return make_key_for_class<World, UnitClass, Provider>(goals, provider);
584 }
585
586 template <typename World, typename Class, typename Provider>
587 [[nodiscard]] static auto make_key_for_class(std::span<const Coord3> goals,
588 const Provider& provider)
589 -> Key {
590 using Model = ResolvedTransitionModel<World, Class, Provider>;
591 const auto model = Model{provider};
592 return Key{
593 detail::tag_identity<typename Model::class_type>(),
594 detail::tile_count<World>(),
595 World::chunk_count,
596 World::local_tile_count,
597 ShapeTraits<typename World::shape_type>::size,
598 ShapeTraits<typename World::shape_type>::chunk,
599 static_cast<std::uint32_t>(Model::lattice_identity),
600 Model::lattice_version,
601 static_cast<std::uint32_t>(Model::step_policy_identity),
602 Model::cost_scale,
603 detail::tag_identity<Provider>(),
604 detail::transition_provider_instance_identity(provider),
605 model.revision(),
606 std::vector<Coord3>{goals.begin(), goals.end()},
607 };
608 }
609
610 [[nodiscard]] static auto keys_equal(const Key& lhs, const Key& rhs) noexcept
611 -> bool {
612 return lhs.movement_class == rhs.movement_class &&
613 lhs.tile_count == rhs.tile_count &&
614 lhs.chunk_count == rhs.chunk_count &&
615 lhs.local_tile_count == rhs.local_tile_count &&
616 lhs.shape_size == rhs.shape_size &&
617 lhs.chunk_extent == rhs.chunk_extent &&
618 lhs.lattice_identity == rhs.lattice_identity &&
619 lhs.lattice_version == rhs.lattice_version &&
620 lhs.step_identity == rhs.step_identity &&
621 lhs.cost_scale == rhs.cost_scale &&
622 lhs.provider_identity == rhs.provider_identity &&
623 lhs.provider_instance_identity == rhs.provider_instance_identity &&
624 lhs.provider_revision == rhs.provider_revision &&
625 lhs.goals == rhs.goals;
626 }
627
628 template <typename World, typename Tag, typename Provider>
629 [[nodiscard]] static auto key_matches(const Key& key,
630 std::span<const Coord3> goals,
631 const Provider& provider) noexcept
632 -> bool {
633 using Class = movement::movement_class_of<Tag>;
634 using UnitClass = movement::detail::UnitMovementClass<Class>;
635 return key_matches_class<World, UnitClass, Provider>(key, goals, provider);
636 }
637
638 template <typename World, typename Class, typename Provider>
639 [[nodiscard]] static auto key_matches_class(const Key& key,
640 std::span<const Coord3> goals,
641 const Provider& provider) noexcept
642 -> bool {
643 using Model = ResolvedTransitionModel<World, Class, Provider>;
644 const auto model = Model{provider};
645 return key.movement_class ==
646 detail::tag_identity<typename Model::class_type>() &&
647 key.tile_count == detail::tile_count<World>() &&
648 key.chunk_count == World::chunk_count &&
649 key.local_tile_count == World::local_tile_count &&
650 key.shape_size == ShapeTraits<typename World::shape_type>::size &&
651 key.chunk_extent == ShapeTraits<typename World::shape_type>::chunk &&
652 key.lattice_identity ==
653 static_cast<std::uint32_t>(Model::lattice_identity) &&
654 key.lattice_version == Model::lattice_version &&
655 key.step_identity ==
656 static_cast<std::uint32_t>(Model::step_policy_identity) &&
657 key.cost_scale == Model::cost_scale &&
658 key.provider_identity == detail::tag_identity<Provider>() &&
659 key.provider_instance_identity ==
660 detail::transition_provider_instance_identity(provider) &&
661 key.provider_revision == model.revision() &&
662 key.goals.size() == goals.size() &&
663 std::equal(key.goals.begin(), key.goals.end(), goals.begin());
664 }
665
666 [[nodiscard]] static auto entry_byte_size(
667 const Key& key, const DistanceFieldProduct& product) noexcept
668 -> std::size_t {
669 return sizeof(Entry) + sizeof(DistanceFieldProduct) +
670 key.goals.size() * sizeof(Coord3) + product.byte_size();
671 }
672
673 void erase_entry(std::size_t index) noexcept {
674 bytes_ -= entries_[index].bytes;
675 entries_.erase(entries_.begin() + static_cast<std::ptrdiff_t>(index));
676 }
677
678 // Returns the storage of one evicted product, if any, so a caller can
679 // take it back instead of allocating a fresh world-sized buffer on its
680 // next build. Whichever eviction happens to be last is fine: every
681 // evicted product carries usable capacity, and the caller only needs
682 // one.
683 auto evict_to_budget() noexcept -> std::unique_ptr<DistanceFieldProduct> {
684 std::unique_ptr<DistanceFieldProduct> recycled;
685 while (bytes_ > byte_budget_ && !entries_.empty()) {
686 auto oldest = std::size_t{0};
687 for (std::size_t i = 1; i < entries_.size(); ++i) {
688 if (entries_[i].last_used < entries_[oldest].last_used) {
689 oldest = i;
690 }
691 }
692 recycled = std::move(entries_[oldest].product);
693 erase_entry(oldest);
694 ++evictions_;
695 }
696 return recycled;
697 }
698
699 std::vector<Entry> entries_;
700 std::size_t byte_budget_ = 0;
701 std::size_t bytes_ = 0;
702 std::size_t hits_ = 0;
703 std::size_t misses_ = 0;
704 std::size_t evictions_ = 0;
705 std::size_t stale_rejections_ = 0;
706 std::uint64_t clock_ = 0;
707};
708
709namespace detail {
710
711template <typename World, typename Model>
712void capture_field_product_dependencies(
713 const World& world, std::span<const std::uint64_t> touched,
714 std::vector<std::uint8_t>& seen, ContentVersionDependencies& dependencies,
715 const Model& model) {
716 using Shape = typename World::shape_type;
717 using Traits = ShapeTraits<Shape>;
718
719 seen.assign(static_cast<std::size_t>(World::chunk_count), 0);
720 if constexpr (Model::preserves_default_connectivity &&
721 std::is_same_v<typename Model::step_policy,
722 movement::DefaultSteps>) {
723 // Default orthogonal steps cross at most one chunk face. Expanding the
724 // reached chunk set by one face therefore captures every blocked frontier
725 // tile without re-enumerating transitions for every reached tile.
726 const auto reached_chunks = dependencies.size();
727 for (std::size_t i = 0; i < reached_chunks; ++i) {
728 seen[static_cast<std::size_t>(dependencies.chunks()[i].key.value)] = 1;
729 }
730 for (std::size_t i = 0; i < reached_chunks; ++i) {
731 const auto center = chunk_coord<Shape>(dependencies.chunks()[i].key);
732 const auto add = [&](ChunkCoord3 neighbor) {
733 const auto key = chunk_key<Shape>(neighbor);
734 auto& mark = seen[static_cast<std::size_t>(key.value)];
735 if (mark == 0) {
736 mark = 1;
737 dependencies.add_chunk_unique(world, key);
738 }
739 };
740 if (center.x > 0) {
741 add(ChunkCoord3{center.x - 1, center.y, center.z});
742 }
743 if (center.x + 1 < Traits::chunk_count_x) {
744 add(ChunkCoord3{center.x + 1, center.y, center.z});
745 }
746 if (center.y > 0) {
747 add(ChunkCoord3{center.x, center.y - 1, center.z});
748 }
749 if (center.y + 1 < Traits::chunk_count_y) {
750 add(ChunkCoord3{center.x, center.y + 1, center.z});
751 }
752 if (center.z > 0) {
753 add(ChunkCoord3{center.x, center.y, center.z - 1});
754 }
755 if (center.z + 1 < Traits::chunk_count_z) {
756 add(ChunkCoord3{center.x, center.y, center.z + 1});
757 }
758 }
759 } else {
760 // Diagonal clearance, hex seams, and provider edges require the exact
761 // transition-level dependency enumeration.
762 for (const auto dependency : dependencies.chunks()) {
763 seen[static_cast<std::size_t>(dependency.key.value)] = 1;
764 }
765 for (const auto index : touched) {
766 model.for_each_dependency_chunk(
767 world, tile_coord<Shape>(index), [&](ChunkKey key) {
768 auto& mark = seen[static_cast<std::size_t>(key.value)];
769 if (mark == 0) {
770 mark = 1;
771 dependencies.add_chunk_unique(world, key);
772 }
773 });
774 }
775 }
776 if constexpr (Model::has_special_transitions) {
777 // A provider may derive an edge from state outside the regular stencil.
778 // Without a generic provider index, whole-world capture is the only sound
779 // invalidation rule for persistent dense products.
780 dependencies.capture_all(world);
781 }
782}
783
784} // namespace detail
785
790template <typename World, typename Tag, typename Provider>
791[[nodiscard]] auto build_distance_field_product(const World& world,
792 const GoalSet& goals,
793 DistanceFieldProduct& product,
794 DistanceFieldScratch& scratch,
795 const Provider& provider)
797 using Shape = typename World::shape_type;
798 using Class = movement::movement_class_of<Tag>;
799 using UnitClass = movement::detail::UnitMovementClass<Class>;
801 // Sizes its distance arrays by the global tile count and treats missing
802 // chunks as blocked with no MissingChunkPolicy, so on a sparse world it would
803 // allocate for the whole (possibly astronomical) shape. It is intentionally
804 // dense-only; this also transitively
805 // guards distance_field_product_path and nearest_target, which only consume
806 // a product built here.
807 static_assert(
808 std::is_same_v<typename World::residency_type, AlwaysResident>,
809 "build_distance_field_product requires an AlwaysResidentWorld; use "
810 "build_distance_field for sparse worlds.");
811 constexpr auto infinite_distance = std::numeric_limits<std::uint32_t>::max();
812
813 TESS_DIAG_EVENT_VALUE(path_clear, scratch.touched_.size());
814 scratch.clear_build();
815 product.clear();
816 const auto model = Model{provider};
817 product.goals_.assign(goals.goals().begin(), goals.goals().end());
818 product.tile_count_ = detail::tile_count<World>();
819 product.chunk_count_ = World::chunk_count;
820 product.local_tile_count_ = World::local_tile_count;
821 product.shape_size_ = ShapeTraits<Shape>::size;
822 product.chunk_extent_ = ShapeTraits<Shape>::chunk;
823 product.model_class_identity_ =
824 detail::tag_identity<typename Model::class_type>();
825 product.model_lattice_identity_ =
826 static_cast<std::uint32_t>(Model::lattice_identity);
827 product.model_lattice_version_ = Model::lattice_version;
828 product.model_step_identity_ =
829 static_cast<std::uint32_t>(Model::step_policy_identity);
830 product.model_cost_scale_ = Model::cost_scale;
831 product.model_provider_identity_ = detail::tag_identity<Provider>();
832 product.model_provider_instance_identity_ =
833 detail::transition_provider_instance_identity(provider);
834 product.model_provider_revision_ = model.revision();
835
836 if (goals.empty()) {
837 return DistanceFieldResult{PathStatus::InvalidGoal, 0, 0};
838 }
839 for (const auto goal : goals.goals()) {
840 if (!contains<Shape>(goal)) {
841 return DistanceFieldResult{PathStatus::InvalidGoal, 0, 0};
842 }
843 TESS_DIAG_EVENT(path_goal_passability_check);
844 if (!detail::is_passable<World, Tag>(world, goal)) {
845 return DistanceFieldResult{PathStatus::InvalidGoal, 0, 0};
846 }
847 }
848
849 const auto node_count = detail::tile_count<World>();
850 if (scratch.distance_.size() != node_count) {
851 TESS_DIAG_EVENT(path_initialize);
852 scratch.generation_.assign(node_count, 0);
853 scratch.distance_.assign(node_count, infinite_distance);
854 }
855
856 for (const auto goal : goals.goals()) {
857 const auto goal_index = detail::tile_index<Shape>(goal);
858 const auto goal_offset = static_cast<std::size_t>(goal_index);
859 if (scratch.is_current(goal_offset)) {
860 continue;
861 }
862 scratch.distance_[goal_offset] = 0;
863 scratch.touch_node(goal_index);
864 TESS_DIAG_EVENT(path_touch_node);
865 if constexpr (Model::cost_scale == 1 && !Model::has_special_transitions) {
866 scratch.frontier_.push_back(goal_index);
867 TESS_DIAG_EVENT(path_heap_push);
868 } else {
869 scratch.weighted_frontier_.push_back(
870 detail::PackedOpenNode::make(goal_index, 0, 0));
871 std::push_heap(scratch.weighted_frontier_.begin(),
872 scratch.weighted_frontier_.end(),
873 detail::packed_open_node_less);
874 TESS_DIAG_EVENT(path_heap_push);
875 }
876 }
877
878 std::size_t expanded_nodes = 0;
879 auto cost_overflow = false;
880 if constexpr (Model::cost_scale == 1 && !Model::has_special_transitions) {
881 std::size_t head = 0;
882 while (head < scratch.frontier_.size()) {
883 const auto current = scratch.frontier_[head];
884 ++head;
885 TESS_DIAG_EVENT(path_heap_pop);
886 ++expanded_nodes;
887
888 const auto current_offset = static_cast<std::size_t>(current);
889 const auto current_distance =
890 scratch.distance_at(current_offset, infinite_distance);
891 const auto current_coord = detail::tile_coord<Shape>(current);
892 const auto visit_neighbor = [&](std::uint64_t neighbor_index) {
893 const auto neighbor_offset = static_cast<std::size_t>(neighbor_index);
894 if (scratch.is_current(neighbor_offset)) {
895 TESS_DIAG_EVENT(path_neighbor_closed);
896 return;
897 }
898
899 scratch.distance_[neighbor_offset] = current_distance + 1;
900 scratch.touch_node(neighbor_index);
901 TESS_DIAG_EVENT(path_touch_node);
902 scratch.frontier_.push_back(neighbor_index);
903 TESS_DIAG_EVENT(path_heap_push);
904 };
905 if constexpr (Model::preserves_default_connectivity &&
906 std::is_same_v<typename Model::step_policy,
908 detail::for_each_indexed_axis_neighbor<Shape>(
909 current_coord, current, [&](Coord3, std::uint64_t neighbor_index) {
910 TESS_DIAG_EVENT(path_neighbor_candidate);
911 TESS_DIAG_EVENT(path_passability_check);
912 if (!detail::is_passable_index<World, Tag>(world,
913 neighbor_index)) {
914 TESS_DIAG_EVENT(path_neighbor_blocked);
915 return;
916 }
917 visit_neighbor(neighbor_index);
918 });
919 } else {
920 model.for_each_reverse(world, current_coord, current, [&](auto probe) {
921 TESS_DIAG_EVENT(path_neighbor_candidate);
922 if (probe.availability == TransitionAvailability::Legal) {
923 visit_neighbor(probe.to_index);
924 }
925 });
926 }
927 }
928 } else {
929 while (!scratch.weighted_frontier_.empty()) {
930 TESS_DIAG_EVENT(path_heap_pop);
931 std::pop_heap(scratch.weighted_frontier_.begin(),
932 scratch.weighted_frontier_.end(),
933 detail::packed_open_node_less);
934 const auto current = scratch.weighted_frontier_.back();
935 scratch.weighted_frontier_.pop_back();
936 const auto current_offset = static_cast<std::size_t>(current.index);
937 const auto current_distance =
938 scratch.distance_at(current_offset, infinite_distance);
939 if (current.g() != current_distance) {
940 TESS_DIAG_EVENT_VALUE(path_skip_pop, false);
941 continue;
942 }
943 ++expanded_nodes;
944 const auto current_coord = detail::tile_coord<Shape>(current.index);
945 model.for_each_reverse(
946 world, current_coord, current.index, [&](auto probe) {
947 TESS_DIAG_EVENT(path_neighbor_candidate);
948 if (probe.availability != TransitionAvailability::Legal) {
949 return;
950 }
951 if (probe.cost_overflow) {
952 cost_overflow = true;
953 return;
954 }
955 const auto neighbor_offset =
956 static_cast<std::size_t>(probe.to_index);
957 TESS_DIAG_EVENT(path_relax_attempt);
958 if (!scratch.is_current(neighbor_offset)) {
959 scratch.distance_[neighbor_offset] = infinite_distance;
960 scratch.touch_node(probe.to_index);
961 TESS_DIAG_EVENT(path_touch_node);
962 }
963 const auto next_distance =
964 detail::saturating_add(current_distance, probe.cost);
965 if (next_distance == infinite_distance) {
966 cost_overflow = true;
967 return;
968 }
969 if (next_distance < scratch.distance_[neighbor_offset]) {
970 TESS_DIAG_EVENT(path_relax_success);
971 scratch.distance_[neighbor_offset] = next_distance;
972 scratch.weighted_frontier_.push_back(detail::PackedOpenNode::make(
973 probe.to_index, next_distance, next_distance));
974 std::push_heap(scratch.weighted_frontier_.begin(),
975 scratch.weighted_frontier_.end(),
976 detail::packed_open_node_less);
977 TESS_DIAG_EVENT(path_heap_push);
978 }
979 });
980 }
981 }
982
983 product.distance_.assign(node_count, infinite_distance);
984 for (const auto index : scratch.touched_) {
985 const auto offset = static_cast<std::size_t>(index);
986 product.distance_[offset] = scratch.distance_[offset];
987 const auto key = tile_key<Shape>(detail::tile_coord<Shape>(index));
988 product.dependencies_.add_chunk(world, chunk_key<Shape>(key));
989 }
990 // The flood never touches a node inside a fully-blocked chunk, but an edit
991 // that opens one changes reachability, so capture the entire frontier too.
992 detail::capture_field_product_dependencies<World, Model>(
993 world, scratch.touched_, scratch.chunk_seen_, product.dependencies_,
994 model);
995 product.status_ =
996 cost_overflow ? PathStatus::CostOverflow : PathStatus::Found;
997 product.expanded_nodes_ = expanded_nodes;
998 product.reached_nodes_ = scratch.touched_.size();
999
1000 return DistanceFieldResult{product.status_, product.expanded_nodes_,
1001 product.reached_nodes_};
1002}
1003
1004template <typename World, typename Tag>
1005[[nodiscard]] auto build_distance_field_product(const World& world,
1006 const GoalSet& goals,
1007 DistanceFieldProduct& product,
1008 DistanceFieldScratch& scratch)
1011 world, goals, product, scratch, AdjacentTransitions{});
1012}
1013
1015template <typename World, typename Class, typename Provider>
1017 const World& world, const GoalSet& goals, DistanceFieldProduct& product,
1018 DistanceFieldScratch& scratch, const Provider& provider)
1020 static_assert(std::derived_from<Class, movement::movement_class_tag>,
1021 "build_weighted_distance_field_product requires Class to be a "
1022 "MovementClass");
1023 static_assert(std::is_same_v<typename World::residency_type, AlwaysResident>,
1024 "weighted distance-field products are dense-only");
1025 using Shape = typename World::shape_type;
1027 constexpr auto infinite_distance = std::numeric_limits<std::uint32_t>::max();
1028
1029 scratch.clear_build();
1030 product.clear();
1031 const auto model = Model{provider};
1032 product.goals_.assign(goals.goals().begin(), goals.goals().end());
1033 product.tile_count_ = detail::tile_count<World>();
1034 product.chunk_count_ = World::chunk_count;
1035 product.local_tile_count_ = World::local_tile_count;
1036 product.shape_size_ = ShapeTraits<Shape>::size;
1037 product.chunk_extent_ = ShapeTraits<Shape>::chunk;
1038 product.model_class_identity_ = detail::tag_identity<Class>();
1039 product.model_lattice_identity_ =
1040 static_cast<std::uint32_t>(Model::lattice_identity);
1041 product.model_lattice_version_ = Model::lattice_version;
1042 product.model_step_identity_ =
1043 static_cast<std::uint32_t>(Model::step_policy_identity);
1044 product.model_cost_scale_ = Model::cost_scale;
1045 product.model_provider_identity_ = detail::tag_identity<Provider>();
1046 product.model_provider_instance_identity_ =
1047 detail::transition_provider_instance_identity(provider);
1048 product.model_provider_revision_ = model.revision();
1049
1050 if (goals.empty()) {
1051 return {PathStatus::InvalidGoal, 0, 0};
1052 }
1053 for (const auto goal : goals.goals()) {
1054 if (!contains<Shape>(goal) ||
1055 !detail::is_passable<World, Class>(world, goal)) {
1056 return {PathStatus::InvalidGoal, 0, 0};
1057 }
1058 const auto index = detail::tile_index<Shape>(goal);
1059 if (detail::tile_entry_cost_index<World, Class>(world, index) == 0) {
1060 return {PathStatus::InvalidGoal, 0, 0};
1061 }
1062 }
1063
1064 const auto node_count = detail::tile_count<World>();
1065 if (scratch.distance_.size() != node_count) {
1066 scratch.generation_.assign(node_count, 0);
1067 scratch.distance_.assign(node_count, infinite_distance);
1068 }
1069 for (const auto goal : goals.goals()) {
1070 const auto index = detail::tile_index<Shape>(goal);
1071 const auto offset = static_cast<std::size_t>(index);
1072 if (scratch.is_current(offset)) {
1073 continue;
1074 }
1075 scratch.distance_[offset] = 0;
1076 scratch.touch_node(index);
1077 scratch.weighted_frontier_.push_back(
1078 detail::PackedOpenNode::make(index, 0, 0));
1079 std::push_heap(scratch.weighted_frontier_.begin(),
1080 scratch.weighted_frontier_.end(),
1081 detail::packed_open_node_less);
1082 }
1083
1084 auto expanded_nodes = std::size_t{0};
1085 auto cost_overflow = false;
1086 while (!scratch.weighted_frontier_.empty()) {
1087 std::pop_heap(scratch.weighted_frontier_.begin(),
1088 scratch.weighted_frontier_.end(),
1089 detail::packed_open_node_less);
1090 const auto current = scratch.weighted_frontier_.back();
1091 scratch.weighted_frontier_.pop_back();
1092 const auto current_offset = static_cast<std::size_t>(current.index);
1093 const auto current_distance =
1094 scratch.distance_at(current_offset, infinite_distance);
1095 if (current.g() != current_distance) {
1096 continue;
1097 }
1098 ++expanded_nodes;
1099 model.for_each_reverse(
1100 world, detail::tile_coord<Shape>(current.index), current.index,
1101 [&](auto probe) {
1102 if (probe.availability != TransitionAvailability::Legal) {
1103 return;
1104 }
1105 if (probe.cost_overflow) {
1106 cost_overflow = true;
1107 return;
1108 }
1109 const auto offset = static_cast<std::size_t>(probe.to_index);
1110 if (!scratch.is_current(offset)) {
1111 scratch.distance_[offset] = infinite_distance;
1112 scratch.touch_node(probe.to_index);
1113 }
1114 const auto next =
1115 detail::saturating_add(current_distance, probe.cost);
1116 if (next == infinite_distance) {
1117 cost_overflow = true;
1118 return;
1119 }
1120 if (next < scratch.distance_[offset]) {
1121 scratch.distance_[offset] = next;
1122 scratch.weighted_frontier_.push_back(
1123 detail::PackedOpenNode::make(probe.to_index, next, next));
1124 std::push_heap(scratch.weighted_frontier_.begin(),
1125 scratch.weighted_frontier_.end(),
1126 detail::packed_open_node_less);
1127 }
1128 });
1129 }
1130
1131 product.distance_.assign(node_count, infinite_distance);
1132 for (const auto index : scratch.touched_) {
1133 const auto offset = static_cast<std::size_t>(index);
1134 product.distance_[offset] = scratch.distance_[offset];
1135 product.dependencies_.add_chunk(
1136 world,
1137 chunk_key<Shape>(tile_key<Shape>(detail::tile_coord<Shape>(index))));
1138 }
1139 detail::capture_field_product_dependencies<World, Model>(
1140 world, scratch.touched_, scratch.chunk_seen_, product.dependencies_,
1141 model);
1142 product.status_ =
1143 cost_overflow ? PathStatus::CostOverflow : PathStatus::Found;
1144 product.expanded_nodes_ = expanded_nodes;
1145 product.reached_nodes_ = scratch.touched_.size();
1146 return {product.status_, product.expanded_nodes_, product.reached_nodes_};
1147}
1148
1150template <typename World, typename Class>
1151[[nodiscard]] auto build_weighted_distance_field_product(
1152 const World& world, const GoalSet& goals, DistanceFieldProduct& product,
1153 DistanceFieldScratch& scratch) -> DistanceFieldResult {
1154 return build_weighted_distance_field_product<World, Class,
1155 AdjacentTransitions>(
1156 world, goals, product, scratch, AdjacentTransitions{});
1157}
1158
1162template <typename World, typename Tag, typename Provider>
1164 const World& world, Coord3 start, const DistanceFieldProduct& product,
1165 DistanceFieldScratch& scratch, const Provider& provider) -> PathResult {
1166 using Shape = typename World::shape_type;
1167 using Class = movement::movement_class_of<Tag>;
1168 using UnitClass = movement::detail::UnitMovementClass<Class>;
1170 const auto model = Model{provider};
1171 // Indexes product.distance_ by raw tile id, so it is dense-only. The
1172 // single-goal build_distance_field / distance_field_path pair is already
1173 // sparse-capable.
1174 static_assert(
1175 std::is_same_v<typename World::residency_type, AlwaysResident>,
1176 "distance_field_product_path requires an AlwaysResidentWorld; use "
1177 "distance_field_path for sparse worlds.");
1178 constexpr auto infinite_distance = std::numeric_limits<std::uint32_t>::max();
1179
1180 scratch.clear_path();
1181 if (!contains<Shape>(start)) {
1182 return PathResult{PathStatus::InvalidStart, 0, 0, 0, scratch.path_};
1183 }
1184 TESS_DIAG_EVENT(path_start_passability_check);
1185 if (!detail::is_passable<World, Tag>(world, start)) {
1186 return PathResult{PathStatus::InvalidStart, 0, 0, 0, scratch.path_};
1187 }
1188 if (product.status_ != PathStatus::Found || !product.is_valid(world) ||
1189 product.tile_count_ != detail::tile_count<World>() ||
1190 product.chunk_count_ != World::chunk_count ||
1191 product.local_tile_count_ != World::local_tile_count ||
1192 product.shape_size_ != ShapeTraits<Shape>::size ||
1193 product.chunk_extent_ != ShapeTraits<Shape>::chunk ||
1194 product.model_class_identity_ !=
1195 detail::tag_identity<typename Model::class_type>() ||
1196 product.model_lattice_identity_ !=
1197 static_cast<std::uint32_t>(Model::lattice_identity) ||
1198 product.model_lattice_version_ != Model::lattice_version ||
1199 product.model_step_identity_ !=
1200 static_cast<std::uint32_t>(Model::step_policy_identity) ||
1201 product.model_cost_scale_ != Model::cost_scale ||
1202 product.model_provider_identity_ != detail::tag_identity<Provider>() ||
1203 product.model_provider_instance_identity_ !=
1204 detail::transition_provider_instance_identity(provider) ||
1205 product.model_provider_revision_ != model.revision()) {
1206 return PathResult{PathStatus::NotComputed, 0, 0, 0, scratch.path_};
1207 }
1208
1209 const auto start_index = detail::tile_index<Shape>(start);
1210 auto current = start_index;
1211 auto current_distance = product.distance_[static_cast<std::size_t>(current)];
1212 if (current_distance == infinite_distance) {
1213 return PathResult{PathStatus::NoPath, 0, 0, product.reached_nodes_,
1214 scratch.path_};
1215 }
1216
1217 scratch.path_.push_back(start);
1218 TESS_DIAG_EVENT(path_reconstruct_node);
1219 while (current_distance > 0) {
1220 const auto current_coord = detail::tile_coord<Shape>(current);
1221 auto next = current;
1222 auto next_distance = current_distance;
1223 if constexpr (Model::preserves_default_connectivity &&
1224 std::is_same_v<typename Model::step_policy,
1226 detail::for_each_indexed_axis_neighbor<Shape>(
1227 current_coord, current, [&](Coord3, std::uint64_t neighbor_index) {
1228 const auto neighbor_distance =
1229 product.distance_[static_cast<std::size_t>(neighbor_index)];
1230 if (neighbor_distance < next_distance) {
1231 next = neighbor_index;
1232 next_distance = neighbor_distance;
1233 }
1234 });
1235 if (next == current || next_distance + 1 != current_distance) {
1236 scratch.path_.clear();
1237 return PathResult{PathStatus::NotComputed, 0, 0, product.reached_nodes_,
1238 scratch.path_};
1239 }
1240 } else {
1241 auto next_cost = std::uint32_t{0};
1242 model.for_each_forward(world, current_coord, current, [&](auto probe) {
1243 if (probe.availability != TransitionAvailability::Legal ||
1244 probe.cost_overflow) {
1245 return;
1246 }
1247 const auto neighbor_distance =
1248 product.distance_[static_cast<std::size_t>(probe.to_index)];
1249 if (neighbor_distance < next_distance &&
1250 detail::saturating_add(neighbor_distance, probe.cost) ==
1251 current_distance) {
1252 next = probe.to_index;
1253 next_distance = neighbor_distance;
1254 next_cost = probe.cost;
1255 }
1256 });
1257
1258 if (next == current || detail::saturating_add(next_distance, next_cost) !=
1259 current_distance) {
1260 scratch.path_.clear();
1261 return PathResult{PathStatus::NotComputed, 0, 0, product.reached_nodes_,
1262 scratch.path_};
1263 }
1264 }
1265
1266 current = next;
1267 current_distance = product.distance_[static_cast<std::size_t>(current)];
1268 scratch.path_.push_back(detail::tile_coord<Shape>(current));
1269 TESS_DIAG_EVENT(path_reconstruct_node);
1270 }
1271
1272 return PathResult{PathStatus::Found,
1273 product.distance_[static_cast<std::size_t>(start_index)],
1274 scratch.path_.size(),
1275 product.reached_nodes_,
1276 scratch.path_,
1277 Model::cost_scale};
1278}
1279
1280template <typename World, typename Tag>
1282 const World& world, Coord3 start, const DistanceFieldProduct& product,
1283 DistanceFieldScratch& scratch) -> PathResult {
1285 world, start, product, scratch, AdjacentTransitions{});
1286}
1287
1289template <typename World, typename Class, typename Provider>
1291 const World& world, Coord3 start, const DistanceFieldProduct& product,
1292 DistanceFieldScratch& scratch, const Provider& provider) -> PathResult {
1293 static_assert(std::derived_from<Class, movement::movement_class_tag>,
1294 "weighted_distance_field_product_path requires Class to be a "
1295 "MovementClass");
1296 static_assert(std::is_same_v<typename World::residency_type, AlwaysResident>,
1297 "weighted distance-field products are dense-only");
1298 using Shape = typename World::shape_type;
1300 constexpr auto infinite_distance = std::numeric_limits<std::uint32_t>::max();
1301 const auto model = Model{provider};
1302
1303 scratch.clear_path();
1304 if (!contains<Shape>(start) ||
1305 !detail::is_passable<World, Class>(world, start)) {
1306 return {PathStatus::InvalidStart, 0, 0, 0, scratch.path_};
1307 }
1308 const auto start_index = detail::tile_index<Shape>(start);
1309 // A zero normalized entry cost is impassable to every weighted query. Check
1310 // it before a cached distance can be replayed.
1311 if (detail::tile_entry_cost_index<World, Class>(world, start_index) == 0) {
1312 return {PathStatus::InvalidStart, 0, 0, 0, scratch.path_};
1313 }
1314 if (product.status_ != PathStatus::Found || !product.is_valid(world) ||
1315 product.tile_count_ != detail::tile_count<World>() ||
1316 product.chunk_count_ != World::chunk_count ||
1317 product.local_tile_count_ != World::local_tile_count ||
1318 product.shape_size_ != ShapeTraits<Shape>::size ||
1319 product.chunk_extent_ != ShapeTraits<Shape>::chunk ||
1320 product.model_class_identity_ != detail::tag_identity<Class>() ||
1321 product.model_lattice_identity_ !=
1322 static_cast<std::uint32_t>(Model::lattice_identity) ||
1323 product.model_lattice_version_ != Model::lattice_version ||
1324 product.model_step_identity_ !=
1325 static_cast<std::uint32_t>(Model::step_policy_identity) ||
1326 product.model_cost_scale_ != Model::cost_scale ||
1327 product.model_provider_identity_ != detail::tag_identity<Provider>() ||
1328 product.model_provider_instance_identity_ !=
1329 detail::transition_provider_instance_identity(provider) ||
1330 product.model_provider_revision_ != model.revision()) {
1331 return {PathStatus::NotComputed, 0, 0, 0, scratch.path_};
1332 }
1333
1334 auto current = start_index;
1335 auto current_distance = product.distance_[start_index];
1336 if (current_distance == infinite_distance) {
1337 return {PathStatus::NoPath, 0, 0, product.reached_nodes_, scratch.path_};
1338 }
1339 scratch.path_.push_back(start);
1340 while (current_distance > 0) {
1341 auto next = current;
1342 auto next_distance = current_distance;
1343 auto next_cost = std::uint32_t{0};
1344 model.for_each_forward(
1345 world, detail::tile_coord<Shape>(current), current, [&](auto probe) {
1346 if (probe.availability != TransitionAvailability::Legal ||
1347 probe.cost_overflow) {
1348 return;
1349 }
1350 const auto candidate = product.distance_[probe.to_index];
1351 if (candidate < next_distance &&
1352 detail::saturating_add(candidate, probe.cost) ==
1353 current_distance) {
1354 next = probe.to_index;
1355 next_distance = candidate;
1356 next_cost = probe.cost;
1357 }
1358 });
1359 if (next == current ||
1360 detail::saturating_add(next_distance, next_cost) != current_distance) {
1361 scratch.path_.clear();
1362 return {PathStatus::NotComputed, 0, 0, product.reached_nodes_,
1363 scratch.path_};
1364 }
1365 current = next;
1366 current_distance = product.distance_[current];
1367 scratch.path_.push_back(detail::tile_coord<Shape>(current));
1368 }
1369
1370 return {PathStatus::Found, product.distance_[start_index],
1371 scratch.path_.size(), product.reached_nodes_,
1372 scratch.path_, Model::cost_scale};
1373}
1374
1376template <typename World, typename Class>
1377[[nodiscard]] auto weighted_distance_field_product_path(
1378 const World& world, Coord3 start, const DistanceFieldProduct& product,
1379 DistanceFieldScratch& scratch) -> PathResult {
1380 return weighted_distance_field_product_path<World, Class,
1382 world, start, product, scratch, AdjacentTransitions{});
1383}
1384
1388template <typename World, typename Tag, typename Provider>
1389[[nodiscard]] auto nearest_target(const World& world, Coord3 start,
1390 const DistanceFieldProduct& product,
1391 DistanceFieldScratch& scratch,
1392 const Provider& provider)
1394 // Consumes a dense-only distance-field product; guarded directly rather than
1395 // only transitively through distance_field_product_path below.
1396 static_assert(
1397 std::is_same_v<typename World::residency_type, AlwaysResident>,
1398 "nearest_target requires an AlwaysResidentWorld and a dense distance "
1399 "field product.");
1401 world, start, product, scratch, provider);
1402 auto target = Coord3{};
1403 if (path.status == PathStatus::Found && !path.path.empty()) {
1404 target = path.path.back();
1405 }
1406 return NearestTargetResult{
1407 path.status, path.cost, target, path.expanded_nodes,
1408 path.reached_nodes, path.path, path.cost_scale,
1409 };
1410}
1411
1412template <typename World, typename Tag>
1413[[nodiscard]] auto nearest_target(const World& world, Coord3 start,
1414 const DistanceFieldProduct& product,
1415 DistanceFieldScratch& scratch)
1418 world, start, product, scratch, AdjacentTransitions{});
1419}
1420
1422template <typename World, typename Class, typename Provider>
1423[[nodiscard]] auto weighted_nearest_target(const World& world, Coord3 start,
1424 const DistanceFieldProduct& product,
1425 DistanceFieldScratch& scratch,
1426 const Provider& provider)
1428 const auto path = weighted_distance_field_product_path<World, Class>(
1429 world, start, product, scratch, provider);
1430 const auto target = path.status == PathStatus::Found && !path.path.empty()
1431 ? path.path.back()
1432 : Coord3{};
1433 return {path.status, path.cost, target, path.expanded_nodes,
1434 path.reached_nodes, path.path, path.cost_scale};
1435}
1436
1438template <typename World, typename Class>
1439[[nodiscard]] auto weighted_nearest_target(const World& world, Coord3 start,
1440 const DistanceFieldProduct& product,
1441 DistanceFieldScratch& scratch)
1442 -> NearestTargetResult {
1443 return weighted_nearest_target<World, Class, AdjacentTransitions>(
1444 world, start, product, scratch, AdjacentTransitions{});
1445}
1446
1447} // namespace tess
Definition field_product_cache.h:60
auto distance_at(Coord3 coord) const noexcept -> std::uint32_t
Reads the stored distance-to-nearest-goal at coord, or the sentinel.
Definition field_product_cache.h:128
static constexpr std::uint32_t unreachable_distance
Sentinel returned by distance_at for unreached or invalid tiles.
Definition field_product_cache.h:118
friend auto distance_field_product_path(const World &world, Coord3 start, const DistanceFieldProduct &product, DistanceFieldScratch &scratch) -> PathResult
Reconstructs a borrowed path from a valid multi-goal product.
Definition field_product_cache.h:1281
friend auto build_weighted_distance_field_product(const World &world, const GoalSet &goals, DistanceFieldProduct &product, DistanceFieldScratch &scratch, const Provider &provider) -> DistanceFieldResult
Builds a dense multi-goal weighted field into a reusable product.
Definition field_product_cache.h:1016
friend auto weighted_distance_field_product_path(const World &world, Coord3 start, const DistanceFieldProduct &product, DistanceFieldScratch &scratch, const Provider &provider) -> PathResult
Reconstructs an exact weighted path through a valid reusable product.
Definition field_product_cache.h:1290
friend auto nearest_target(const World &world, Coord3 start, const DistanceFieldProduct &product, DistanceFieldScratch &scratch) -> NearestTargetResult
Finds the nearest reachable goal represented by a valid product.
Definition field_product_cache.h:1413
friend auto build_distance_field_product(const World &world, const GoalSet &goals, DistanceFieldProduct &product, DistanceFieldScratch &scratch) -> DistanceFieldResult
Builds a dense multi-goal field into caller-owned reusable storage.
Definition field_product_cache.h:1005
Definition path.h:872
auto lookup_weighted(const World &world, const GoalSet &goals, const Provider &provider) -> const DistanceFieldProduct *
Looks up a weighted product for the exact movement class and provider.
Definition field_product_cache.h:358
auto lookup(const World &world, const GoalSet &goals, const Provider &provider) -> const DistanceFieldProduct *
Looks up a product for the exact provider type and revision.
Definition field_product_cache.h:333
auto lookup_weighted(const World &world, const GoalSet &goals) -> const DistanceFieldProduct *
Looks up a weighted product using regular adjacent transitions.
Definition field_product_cache.h:385
auto store_reusing(DistanceFieldProduct &product, const Provider &provider) -> const DistanceFieldProduct *
Definition field_product_cache.h:440
auto store_reusing(DistanceFieldProduct &product) -> const DistanceFieldProduct *
Stores a product built for ordinary adjacent transitions.
Definition field_product_cache.h:454
auto store_weighted(DistanceFieldProduct &&product) -> bool
Stores a weighted product using regular adjacent transitions.
Definition field_product_cache.h:501
auto store_weighted(DistanceFieldProduct &&product, const Provider &provider) -> bool
Stores a weighted product under its movement class and provider.
Definition field_product_cache.h:462
auto store(DistanceFieldProduct &&product, const Provider &provider) -> bool
Stores a product under the exact provider type and revision.
Definition field_product_cache.h:425
auto store_weighted_reusing(DistanceFieldProduct &product, const Provider &provider) -> const DistanceFieldProduct *
Definition field_product_cache.h:481
Owns an ordered set of goals used to build a reusable distance product.
Definition field_product_cache.h:19
Definition transition_model.h:380
Definition world.h:22
Supplies no special transitions beyond ordinary face adjacency.
Definition transition_provider.h:132
Definition shape.h:46
Reports distance-field construction status and search work.
Definition path.h:70
Definition shape.h:14
Summarizes field-product cache residency and lookup outcomes.
Definition field_product_cache.h:238
Reports the closest reachable goal and a scratch-owned path to it.
Definition field_product_cache.h:42
Definition path.h:60
Definition shape.h:296
Definition step_policy.h:26