tess 1.0.0
Performance-first tile and path simulation substrate
Loading...
Searching...
No Matches
movement.h
1#pragma once
2
3#include <tess/core/shape.h>
4#include <tess/storage/chunk_meta.h>
5#include <tess/storage/residency.h>
6#include <tess/topology/movement_class.h>
7#include <tess/topology/transition_model.h>
8
9#include <cstddef>
10#include <cstdint>
11#include <optional>
12#include <type_traits>
13
14namespace tess {
15
17enum class MovementStatus : std::uint8_t {
18 Moved,
19 InvalidFrom,
20 InvalidTo,
21 NotAdjacent,
22 ImpassableFrom,
23 ImpassableTo,
24 Blocked,
25 Occupied,
26 Reserved,
27 StaleContent,
28 StaleTopology,
29};
30static_assert(sizeof(MovementStatus) == sizeof(std::uint8_t));
31
34 std::optional<ContentVersion> from_content_version;
35 std::optional<ContentVersion> to_content_version;
36 std::optional<TopologyVersion> from_topology_version;
37 std::optional<TopologyVersion> to_topology_version;
38};
39
42 Coord3 from{};
43 Coord3 to{};
44 MovementVersionCheck versions{};
45};
46
49 MovementStatus status = MovementStatus::Moved;
50 Coord3 from{};
51 Coord3 to{};
52};
53
56 std::size_t invalid = 0;
57 std::size_t impassable = 0;
58 std::size_t blocked = 0;
59 std::size_t occupied = 0;
60 std::size_t reserved = 0;
61 std::size_t stale_content = 0;
62 std::size_t stale_topology = 0;
63};
64
66inline void record_movement_failure(MovementFailureCounts& counts,
67 MovementStatus status) noexcept {
68 switch (status) {
69 case MovementStatus::Moved:
70 return;
71 case MovementStatus::InvalidFrom:
72 case MovementStatus::InvalidTo:
73 case MovementStatus::NotAdjacent:
74 ++counts.invalid;
75 return;
76 case MovementStatus::ImpassableFrom:
77 case MovementStatus::ImpassableTo:
78 ++counts.impassable;
79 return;
80 case MovementStatus::Blocked:
81 ++counts.blocked;
82 return;
83 case MovementStatus::Occupied:
84 ++counts.occupied;
85 return;
86 case MovementStatus::Reserved:
87 ++counts.reserved;
88 return;
89 case MovementStatus::StaleContent:
90 ++counts.stale_content;
91 return;
92 case MovementStatus::StaleTopology:
93 ++counts.stale_topology;
94 return;
95 }
96}
97
98// Transient failures describe a world state that can legitimately change
99// under a routed agent (another agent passing through, a fresh wall, a
100// stale content-version guard), so retrying may succeed. How to retry
101// splits by cause. Occupancy and reservation failures should retry the
102// retained step: path passability deliberately ignores both, so a fresh
103// search returns the same route. The rest invalidate the route and need
104// a new search before movement can resume. The remaining failures
105// (invalid endpoints, non-adjacent steps) indicate a caller bug and are
106// terminal.
108[[nodiscard]] constexpr auto is_transient_movement_failure(
109 MovementStatus status) noexcept -> bool {
110 switch (status) {
111 case MovementStatus::ImpassableFrom:
112 case MovementStatus::ImpassableTo:
113 case MovementStatus::Blocked:
114 case MovementStatus::Occupied:
115 case MovementStatus::Reserved:
116 case MovementStatus::StaleContent:
117 case MovementStatus::StaleTopology:
118 return true;
119 case MovementStatus::Moved:
120 case MovementStatus::InvalidFrom:
121 case MovementStatus::InvalidTo:
122 case MovementStatus::NotAdjacent:
123 return false;
124 }
125 return false;
126}
127
128namespace detail {
129
130[[nodiscard]] inline auto movement_versions_match_meta(
131 const ChunkMeta& from_meta, const ChunkMeta& to_meta,
132 const MovementVersionCheck& versions) noexcept -> MovementStatus {
133 if (versions.from_content_version.has_value() &&
134 from_meta.content_version != *versions.from_content_version) {
135 return MovementStatus::StaleContent;
136 }
137 if (versions.to_content_version.has_value() &&
138 to_meta.content_version != *versions.to_content_version) {
139 return MovementStatus::StaleContent;
140 }
141 if (versions.from_topology_version.has_value() &&
142 from_meta.topology_version != *versions.from_topology_version) {
143 return MovementStatus::StaleTopology;
144 }
145 if (versions.to_topology_version.has_value() &&
146 to_meta.topology_version != *versions.to_topology_version) {
147 return MovementStatus::StaleTopology;
148 }
149 return MovementStatus::Moved;
150}
151
152[[nodiscard]] constexpr auto has_version_expectations(
153 const MovementVersionCheck& versions) noexcept -> bool {
154 return versions.from_content_version.has_value() ||
155 versions.to_content_version.has_value() ||
156 versions.from_topology_version.has_value() ||
157 versions.to_topology_version.has_value();
158}
159
160} // namespace detail
161
163template <typename World>
164[[nodiscard]] auto movement_versions_match(const World& world,
165 MovementIntent intent) noexcept
166 -> MovementStatus {
167 if constexpr (std::is_same_v<typename World::residency_type,
169 // A non-resident chunk has no content-version snapshot to compare against;
170 // treat it as stale so the move is rejected rather than reading meta()
171 // out of bounds. This holds even with no expectations set, so the
172 // fast path below cannot change sparse semantics.
173 const auto from = world.resolve(intent.from);
174 const auto to = world.resolve(intent.to);
175 if (!world.is_resident(from.chunk_key) ||
176 !world.is_resident(to.chunk_key)) {
177 return MovementStatus::StaleContent;
178 }
179 if (!detail::has_version_expectations(intent.versions)) {
180 return MovementStatus::Moved;
181 }
182 return detail::movement_versions_match_meta(
183 world.meta(from.chunk_key), world.meta(to.chunk_key), intent.versions);
184 } else {
185 // The movement-scheduler path submits intents with no content-version
186 // expectations at all; resolving both endpoints and reading two metas
187 // just to compare nothing is unnecessary per-step work.
188 if (!detail::has_version_expectations(intent.versions)) {
189 return MovementStatus::Moved;
190 }
191 const auto from = world.resolve(intent.from);
192 const auto to = world.resolve(intent.to);
193 return detail::movement_versions_match_meta(
194 world.meta(from.chunk_key), world.meta(to.chunk_key), intent.versions);
195 }
196}
197
198// `ClassOrTag` is the mover's movement class OR a raw passable tag (normalized
199// exactly as in astar_path), so plan and commit share one vocabulary: every
200// step A* accepted for a class passes validation for that same class. The
201// from- and to-tiles may live on different pages; each is resolved and the
202// class predicate evaluated on its own page.
203
204namespace detail {
205
206// Validation core: returns the resolved endpoints alongside the result so
207// commit_movement_intent reuses them for its field writes and dirty marks
208// instead of re-resolving the same coordinates 4-7x per committed step. This
209// core is intentionally not noexcept: consumer
210// providers are allowed to throw during enumeration, and validation must
211// propagate that failure instead of terminating. Resolved tiles are meaningful
212// only when result.status == Moved.
213template <typename World, typename ClassOrTag, typename OccupancyTag,
214 typename ReservationTag, typename Provider>
215[[nodiscard]] auto validate_movement_intent_resolved(const World& world,
216 MovementIntent intent,
217 const Provider& provider) {
218 using Class = movement::movement_class_of<ClassOrTag>;
219 using Model = ResolvedTransitionModel<World, Class, Provider>;
220 using Resolved = ResolvedTile<typename World::shape_type>;
221 struct Validated {
222 MovementResult result;
223 Resolved from;
224 Resolved to;
225 };
226 const auto fail = [&](MovementStatus status) {
227 return Validated{MovementResult{status, intent.from, intent.to}, Resolved{},
228 Resolved{}};
229 };
230 const auto resolved_from = world.try_resolve(intent.from);
231 if (!resolved_from.has_value()) {
232 return fail(MovementStatus::InvalidFrom);
233 }
234 const auto resolved_to = world.try_resolve(intent.to);
235 if (!resolved_to.has_value()) {
236 return fail(MovementStatus::InvalidTo);
237 }
238 if constexpr (std::is_same_v<typename World::residency_type,
239 SparseResident>) {
240 // try_resolve is containment-only, so an in-bounds but non-resident
241 // endpoint passes the checks above. A non-resident chunk carries no data,
242 // but this is a TRANSIENT condition -- the chunk may be rematerialized --
243 // so return StaleContent (a transient failure, matching
244 // movement_versions_match for the identical condition) rather than a
245 // terminal InvalidFrom/InvalidTo. That routes the agent lifecycle to
246 // re-plan against the now-changed residency instead of permanently
247 // stranding it at Unreachable, and it still short-circuits before the
248 // unchecked accessors below so we never read a non-resident slot out of
249 // bounds. Ordinary LRU eviction of a chunk under a Following agent must
250 // not read as a permanent caller bug.
251 if (!world.is_resident(resolved_from->chunk_key) ||
252 !world.is_resident(resolved_to->chunk_key)) {
253 return fail(MovementStatus::StaleContent);
254 }
255 }
256 const auto& from_page = world.chunk(resolved_from->chunk_key);
257 const auto& to_page = world.chunk(resolved_to->chunk_key);
258 if (!Class::passable(from_page, resolved_from->local_tile_id)) {
259 return fail(MovementStatus::ImpassableFrom);
260 }
261 if (!Class::passable(to_page, resolved_to->local_tile_id)) {
262 return fail(MovementStatus::ImpassableTo);
263 }
264 // Exact search rejects a zero-entry-cost goal before enumerating either
265 // regular or provider transitions. The source was a valid search endpoint
266 // when the route was planned; commit checks its passability above while a
267 // zero-cost destination must invalidate this planned step.
268 if (Class::entry_cost(to_page, resolved_to->local_tile_id) == 0) {
269 return fail(MovementStatus::ImpassableTo);
270 }
271 auto transition_availability = TransitionAvailability::Blocked;
272 auto is_candidate = Model::is_regular_candidate(intent.from, intent.to);
273 if (is_candidate) {
274 transition_availability =
275 Model::regular_availability(world, intent.from, intent.to);
276 }
277 if constexpr (Model::has_special_transitions) {
278 // A provider may deliberately add an edge parallel to a geometric
279 // regular edge (a bridge across blocked diagonal clearance, for example).
280 // A legal regular edge is the overwhelmingly common hot path and needs no
281 // provider call. Otherwise enumerate both sources and accept the strongest
282 // matching result: Legal beats MissingTopology, which beats Blocked.
283 // This keeps commit aligned with the transition enumeration used by A*
284 // without imposing provider overhead on ordinary legal movement.
285 if (transition_availability != TransitionAvailability::Legal) {
286 const auto model = Model{provider};
287 model.for_each_forward(
288 world, intent.from,
289 detail::transition_index<typename World::shape_type>(intent.from),
290 [&](auto probe) {
291 if (probe.to != intent.to) {
292 return;
293 }
294 is_candidate = true;
295 if (probe.availability == TransitionAvailability::Legal) {
296 transition_availability = TransitionAvailability::Legal;
297 } else if (probe.availability ==
298 TransitionAvailability::MissingTopology &&
299 transition_availability !=
300 TransitionAvailability::Legal) {
301 transition_availability = TransitionAvailability::MissingTopology;
302 }
303 });
304 }
305 }
306 if (!is_candidate) {
307 if constexpr (Model::has_special_transitions) {
308 return fail(MovementStatus::StaleTopology);
309 }
310 return fail(MovementStatus::NotAdjacent);
311 }
312 if (transition_availability == TransitionAvailability::MissingTopology) {
313 return fail(MovementStatus::StaleTopology);
314 }
315 if (transition_availability != TransitionAvailability::Legal) {
316 return fail(MovementStatus::Blocked);
317 }
318 if (static_cast<bool>(
319 to_page.template field<OccupancyTag>(resolved_to->local_tile_id))) {
320 return fail(MovementStatus::Occupied);
321 }
322 if (static_cast<bool>(
323 to_page.template field<ReservationTag>(resolved_to->local_tile_id))) {
324 return fail(MovementStatus::Reserved);
325 }
326
327 // Residency (sparse) was checked above, so the meta reads are safe for
328 // both world kinds; with no expectations the compares are skipped
329 // entirely.
330 if (detail::has_version_expectations(intent.versions)) {
331 const auto version_status = detail::movement_versions_match_meta(
332 world.meta(resolved_from->chunk_key),
333 world.meta(resolved_to->chunk_key), intent.versions);
334 if (version_status != MovementStatus::Moved) {
335 return fail(version_status);
336 }
337 }
338 return Validated{
339 MovementResult{MovementStatus::Moved, intent.from, intent.to},
340 *resolved_from, *resolved_to};
341}
342
343} // namespace detail
344
345template <typename World, typename ClassOrTag, typename OccupancyTag,
346 typename ReservationTag>
350[[nodiscard]] auto validate_movement_intent(const World& world,
351 MovementIntent intent) noexcept
352 -> MovementResult {
353 return detail::validate_movement_intent_resolved<World, ClassOrTag,
354 OccupancyTag, ReservationTag,
355 AdjacentTransitions>(
356 world, intent, AdjacentTransitions{})
357 .result;
358}
359
360template <typename World, typename ClassOrTag, typename OccupancyTag,
361 typename ReservationTag, typename Provider>
365[[nodiscard]] auto validate_movement_intent(const World& world,
366 MovementIntent intent,
367 const Provider& provider)
368 -> MovementResult {
369 return detail::validate_movement_intent_resolved<
370 World, ClassOrTag, OccupancyTag, ReservationTag, Provider>(
371 world, intent, provider)
372 .result;
373}
374
375template <typename World, typename ClassOrTag, typename OccupancyTag,
376 typename ReservationTag>
381[[nodiscard]] auto commit_movement_intent(World& world, MovementIntent intent,
382 DirtyMask dirty_mask = {}) noexcept
383 -> MovementResult {
384 const auto validated = detail::validate_movement_intent_resolved<
385 World, ClassOrTag, OccupancyTag, ReservationTag, AdjacentTransitions>(
386 world, intent, AdjacentTransitions{});
387 if (validated.result.status != MovementStatus::Moved) {
388 return validated.result;
389 }
390
391 auto& from_page = world.chunk(validated.from.chunk_key);
392 auto& to_page = world.chunk(validated.to.chunk_key);
393 from_page.template field<OccupancyTag>(validated.from.local_tile_id) = false;
394 to_page.template field<OccupancyTag>(validated.to.local_tile_id) = true;
395 to_page.template field<ReservationTag>(validated.to.local_tile_id) = false;
396 if (dirty_mask) {
397 world.mark_dirty(validated.from.chunk_key, dirty_mask,
398 Box3{intent.from, Extent3{1, 1, 1}});
399 world.mark_dirty(validated.to.chunk_key, dirty_mask,
400 Box3{intent.to, Extent3{1, 1, 1}});
401 }
402 return validated.result;
403}
404
405template <typename World, typename ClassOrTag, typename OccupancyTag,
406 typename ReservationTag, typename Provider>
410[[nodiscard]] auto commit_movement_intent(World& world, MovementIntent intent,
411 DirtyMask dirty_mask,
412 const Provider& provider)
413 -> MovementResult {
414 const auto validated =
415 detail::validate_movement_intent_resolved<World, ClassOrTag, OccupancyTag,
416 ReservationTag, Provider>(
417 world, intent, provider);
418 if (validated.result.status != MovementStatus::Moved) {
419 return validated.result;
420 }
421
422 auto& from_page = world.chunk(validated.from.chunk_key);
423 auto& to_page = world.chunk(validated.to.chunk_key);
424 from_page.template field<OccupancyTag>(validated.from.local_tile_id) = false;
425 to_page.template field<OccupancyTag>(validated.to.local_tile_id) = true;
426 to_page.template field<ReservationTag>(validated.to.local_tile_id) = false;
427 if (dirty_mask) {
428 world.mark_dirty(validated.from.chunk_key, dirty_mask,
429 Box3{intent.from, Extent3{1, 1, 1}});
430 world.mark_dirty(validated.to.chunk_key, dirty_mask,
431 Box3{intent.to, Extent3{1, 1, 1}});
432 }
433 return validated.result;
434}
435
436} // namespace tess
Definition world.h:22
Definition shape.h:46
Aggregates rejected movement attempts by retry-relevant category.
Definition movement.h:55
Describes an adjacent move and any versions it expects to remain current.
Definition movement.h:41
Reports the movement status together with the requested endpoints.
Definition movement.h:48
Holds optional optimistic-concurrency versions for both movement endpoints.
Definition movement.h:33
Definition residency.h:18