tess 0.4.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
8#include <cstddef>
9#include <cstdint>
10#include <optional>
11#include <type_traits>
12
13namespace tess {
14
16enum class MovementStatus : std::uint8_t {
17 Moved,
18 InvalidFrom,
19 InvalidTo,
20 NotAdjacent,
21 BlockedFrom,
22 BlockedTo,
23 Occupied,
24 Reserved,
25 StaleVersion,
26 StaleTopology,
27};
28static_assert(sizeof(MovementStatus) == sizeof(std::uint8_t));
29
32 std::optional<std::uint32_t> from_chunk_version;
33 std::optional<std::uint32_t> to_chunk_version;
34 std::optional<std::uint32_t> from_topology_version;
35 std::optional<std::uint32_t> to_topology_version;
36};
37
40 Coord3 from{};
41 Coord3 to{};
42 MovementVersionCheck versions{};
43};
44
47 MovementStatus status = MovementStatus::Moved;
48 Coord3 from{};
49 Coord3 to{};
50};
51
54 std::size_t invalid = 0;
55 std::size_t blocked = 0;
56 std::size_t occupied = 0;
57 std::size_t reserved = 0;
58 std::size_t stale_version = 0;
59 std::size_t stale_topology = 0;
60};
61
63inline void record_movement_failure(MovementFailureCounts& counts,
64 MovementStatus status) noexcept {
65 switch (status) {
66 case MovementStatus::Moved:
67 return;
68 case MovementStatus::InvalidFrom:
69 case MovementStatus::InvalidTo:
70 case MovementStatus::NotAdjacent:
71 ++counts.invalid;
72 return;
73 case MovementStatus::BlockedFrom:
74 case MovementStatus::BlockedTo:
75 ++counts.blocked;
76 return;
77 case MovementStatus::Occupied:
78 ++counts.occupied;
79 return;
80 case MovementStatus::Reserved:
81 ++counts.reserved;
82 return;
83 case MovementStatus::StaleVersion:
84 ++counts.stale_version;
85 return;
86 case MovementStatus::StaleTopology:
87 ++counts.stale_topology;
88 return;
89 }
90}
91
92// Transient failures describe a world state that can legitimately change
93// under a routed agent (another agent passing through, a fresh wall, a
94// stale version guard); callers should re-path and retry. The remaining
95// failures (invalid endpoints, non-adjacent steps) indicate a caller bug
96// and are terminal.
98[[nodiscard]] constexpr auto is_transient_movement_failure(
99 MovementStatus status) noexcept -> bool {
100 switch (status) {
101 case MovementStatus::BlockedFrom:
102 case MovementStatus::BlockedTo:
103 case MovementStatus::Occupied:
104 case MovementStatus::Reserved:
105 case MovementStatus::StaleVersion:
106 case MovementStatus::StaleTopology:
107 return true;
108 case MovementStatus::Moved:
109 case MovementStatus::InvalidFrom:
110 case MovementStatus::InvalidTo:
111 case MovementStatus::NotAdjacent:
112 return false;
113 }
114 return false;
115}
116
117namespace detail {
118
119[[nodiscard]] inline auto movement_versions_match_meta(
120 const ChunkMeta& from_meta, const ChunkMeta& to_meta,
121 const MovementVersionCheck& versions) noexcept -> MovementStatus {
122 if (versions.from_chunk_version.has_value() &&
123 from_meta.version != *versions.from_chunk_version) {
124 return MovementStatus::StaleVersion;
125 }
126 if (versions.to_chunk_version.has_value() &&
127 to_meta.version != *versions.to_chunk_version) {
128 return MovementStatus::StaleVersion;
129 }
130 if (versions.from_topology_version.has_value() &&
131 from_meta.topology_version != *versions.from_topology_version) {
132 return MovementStatus::StaleTopology;
133 }
134 if (versions.to_topology_version.has_value() &&
135 to_meta.topology_version != *versions.to_topology_version) {
136 return MovementStatus::StaleTopology;
137 }
138 return MovementStatus::Moved;
139}
140
141[[nodiscard]] constexpr auto has_version_expectations(
142 const MovementVersionCheck& versions) noexcept -> bool {
143 return versions.from_chunk_version.has_value() ||
144 versions.to_chunk_version.has_value() ||
145 versions.from_topology_version.has_value() ||
146 versions.to_topology_version.has_value();
147}
148
149} // namespace detail
150
152template <typename World>
153[[nodiscard]] auto movement_versions_match(const World& world,
154 MovementIntent intent) noexcept
155 -> MovementStatus {
156 if constexpr (std::is_same_v<typename World::residency_type,
158 // A non-resident chunk has no version snapshot to compare against;
159 // treat it as stale so the move is rejected rather than reading meta()
160 // out of bounds. This holds even with no expectations set, so the
161 // fast path below cannot change sparse semantics.
162 const auto from = world.resolve(intent.from);
163 const auto to = world.resolve(intent.to);
164 if (!world.is_resident(from.chunk_key) ||
165 !world.is_resident(to.chunk_key)) {
166 return MovementStatus::StaleVersion;
167 }
168 if (!detail::has_version_expectations(intent.versions)) {
169 return MovementStatus::Moved;
170 }
171 return detail::movement_versions_match_meta(
172 world.meta(from.chunk_key), world.meta(to.chunk_key), intent.versions);
173 } else {
174 // The movement-scheduler path submits intents with no version
175 // expectations at all; resolving both endpoints and reading two metas
176 // just to compare nothing was pure per-step waste (audit 2026-07-11
177 // M7).
178 if (!detail::has_version_expectations(intent.versions)) {
179 return MovementStatus::Moved;
180 }
181 const auto from = world.resolve(intent.from);
182 const auto to = world.resolve(intent.to);
183 return detail::movement_versions_match_meta(
184 world.meta(from.chunk_key), world.meta(to.chunk_key), intent.versions);
185 }
186}
187
188// `ClassOrTag` is the mover's movement class OR a raw passable tag (normalized
189// exactly as in astar_path), so plan and commit share one vocabulary: every
190// step A* accepted for a class passes validation for that same class. The
191// from- and to-tiles may live on different pages; each is resolved and the
192// class predicate evaluated on its own page.
193
194namespace detail {
195
196// Validation core: returns the resolved endpoints alongside the result so
197// commit_movement_intent reuses them for its field writes and dirty marks
198// instead of re-resolving the same coordinates 4-7x per committed step
199// (audit 2026-07-11 M7). Resolved tiles are meaningful only when
200// result.status == Moved.
201template <typename World, typename ClassOrTag, typename OccupancyTag,
202 typename ReservationTag>
203[[nodiscard]] auto validate_movement_intent_resolved(
204 const World& world, MovementIntent intent) noexcept {
205 using Class = movement::movement_class_of<ClassOrTag>;
206 using Resolved = ResolvedTile<typename World::shape_type>;
207 struct Validated {
208 MovementResult result;
209 Resolved from;
210 Resolved to;
211 };
212 const auto fail = [&](MovementStatus status) {
213 return Validated{MovementResult{status, intent.from, intent.to}, Resolved{},
214 Resolved{}};
215 };
216 const auto resolved_from = world.try_resolve(intent.from);
217 if (!resolved_from.has_value()) {
218 return fail(MovementStatus::InvalidFrom);
219 }
220 const auto resolved_to = world.try_resolve(intent.to);
221 if (!resolved_to.has_value()) {
222 return fail(MovementStatus::InvalidTo);
223 }
224 if constexpr (std::is_same_v<typename World::residency_type,
225 SparseResident>) {
226 // try_resolve is containment-only, so an in-bounds but non-resident
227 // endpoint passes the checks above. A non-resident chunk carries no data,
228 // but this is a TRANSIENT condition -- the chunk may be reloaded -- so
229 // return StaleVersion (a transient failure, matching
230 // movement_versions_match for the identical condition) rather than a
231 // terminal InvalidFrom/InvalidTo. That routes the agent lifecycle to
232 // re-plan against the now-changed residency instead of permanently
233 // stranding it at Unreachable, and it still short-circuits before the
234 // unchecked accessors below so we never read a non-resident slot out of
235 // bounds. Ordinary LRU eviction of a chunk under a Following agent must
236 // not read as a permanent caller bug.
237 if (!world.is_resident(resolved_from->chunk_key) ||
238 !world.is_resident(resolved_to->chunk_key)) {
239 return fail(MovementStatus::StaleVersion);
240 }
241 }
242 if (manhattan_distance(intent.from, intent.to) != 1) {
243 return fail(MovementStatus::NotAdjacent);
244 }
245 const auto& from_page = world.chunk(resolved_from->chunk_key);
246 const auto& to_page = world.chunk(resolved_to->chunk_key);
247 if (!Class::passable(from_page, resolved_from->local_tile_id)) {
248 return fail(MovementStatus::BlockedFrom);
249 }
250 if (!Class::passable(to_page, resolved_to->local_tile_id)) {
251 return fail(MovementStatus::BlockedTo);
252 }
253 if (static_cast<bool>(
254 to_page.template field<OccupancyTag>(resolved_to->local_tile_id))) {
255 return fail(MovementStatus::Occupied);
256 }
257 if (static_cast<bool>(
258 to_page.template field<ReservationTag>(resolved_to->local_tile_id))) {
259 return fail(MovementStatus::Reserved);
260 }
261
262 // Residency (sparse) was checked above, so the meta reads are safe for
263 // both world kinds; with no expectations the compares are skipped
264 // entirely.
265 if (detail::has_version_expectations(intent.versions)) {
266 const auto version_status = detail::movement_versions_match_meta(
267 world.meta(resolved_from->chunk_key),
268 world.meta(resolved_to->chunk_key), intent.versions);
269 if (version_status != MovementStatus::Moved) {
270 return fail(version_status);
271 }
272 }
273 return Validated{
274 MovementResult{MovementStatus::Moved, intent.from, intent.to},
275 *resolved_from, *resolved_to};
276}
277
278} // namespace detail
279
280template <typename World, typename ClassOrTag, typename OccupancyTag,
281 typename ReservationTag>
285[[nodiscard]] auto validate_movement_intent(const World& world,
286 MovementIntent intent) noexcept
287 -> MovementResult {
288 return detail::validate_movement_intent_resolved<
289 World, ClassOrTag, OccupancyTag, ReservationTag>(world, intent)
290 .result;
291}
292
293template <typename World, typename ClassOrTag, typename OccupancyTag,
294 typename ReservationTag>
299auto commit_movement_intent(World& world, MovementIntent intent,
300 std::uint32_t dirty_mask = 0) noexcept
301 -> MovementResult {
302 const auto validated =
303 detail::validate_movement_intent_resolved<World, ClassOrTag, OccupancyTag,
304 ReservationTag>(world, intent);
305 if (validated.result.status != MovementStatus::Moved) {
306 return validated.result;
307 }
308
309 auto& from_page = world.chunk(validated.from.chunk_key);
310 auto& to_page = world.chunk(validated.to.chunk_key);
311 from_page.template field<OccupancyTag>(validated.from.local_tile_id) = false;
312 to_page.template field<OccupancyTag>(validated.to.local_tile_id) = true;
313 to_page.template field<ReservationTag>(validated.to.local_tile_id) = false;
314 if (dirty_mask != 0) {
315 world.mark_dirty(validated.from.chunk_key, dirty_mask,
316 Box3{intent.from, Extent3{1, 1, 1}});
317 world.mark_dirty(validated.to.chunk_key, dirty_mask,
318 Box3{intent.to, Extent3{1, 1, 1}});
319 }
320 return validated.result;
321}
322
323} // namespace tess
Definition world.h:22
Definition shape.h:75
Definition shape.h:30
Definition shape.h:13
Aggregates rejected movement attempts by retry-relevant category.
Definition movement.h:53
Describes an adjacent move and any versions it expects to remain current.
Definition movement.h:39
Reports the movement status together with the requested endpoints.
Definition movement.h:46
Holds optional optimistic-concurrency versions for both movement endpoints.
Definition movement.h:31
Definition residency.h:17