tess 1.0.0
Performance-first tile and path simulation substrate
Loading...
Searching...
No Matches
transition_provider.h
1#pragma once
2
3#include <tess/core/shape.h>
4#include <tess/storage/residency.h>
5
6#include <concepts>
7#include <cstdint>
8#include <memory>
9#include <type_traits>
10
11namespace tess {
12
15 Coord3 to{};
16 std::uint32_t cost = 1;
17 bool missing_topology = false;
18};
19
20// A transition provider contributes EXTRA directed tile-to-tile transitions
21// to the region graph, beyond the built-in six-axis face adjacency (stairs,
22// ladders, and similar special movement for a class). The graph builders
23// enumerate a provider once per chunk and append one directed RegionPortal
24// per transition whose endpoints both resolve to labeled regions.
25//
26// Contract:
27// - `for_each_transition(world, chunk, sink)` invokes `sink(from, to)` once
28// per directed transition ORIGINATING in `chunk` (`from` must lie in that
29// chunk). Bidirectional passages emit both directions, each from its own
30// chunk's enumeration. A transition whose `from` lies outside `chunk` is
31// SILENTLY DROPPED, in every build. Dropped rather than honored because
32// incremental removal keys on the source's chunk, so keeping one would
33// grow the portal set without bound and make incremental output diverge
34// from a full rebuild; silent rather than reported because a counter for
35// a caller bug would be public diagnostics surface. If a provider's edges
36// go missing and routes come back unexpectedly `Unreachable`, check this
37// first: enumerate the provider directly and confirm every `from`
38// resolves to the chunk it was asked about.
39// - `to` must lie in the same chunk or a regular-step neighbor chunk (the six
40// orthogonal faces, plus the two diagonal seams of an axial-hex lattice).
41// Incremental updates re-derive portals only for dirty chunks and those
42// neighbors, so a longer-range transition would survive, stale, past an
43// edit to its landing chunk (asserted in debug builds).
44// - Enumeration must be deterministic for identical world content: the
45// incremental-equals-full-rebuild invariant compares portal order.
46// - Transitions whose endpoints are not passable for the graph's movement
47// class contribute nothing (their tiles hold no region label). On a sparse
48// world a transition landing in a NON-RESIDENT chunk marks the origin
49// region as reaching missing topology, so reachability degrades to
50// Indeterminate rather than a wrong Unreachable.
51//
52// The provider type, live object identity, and revision are stamped on the
53// graph at build time (mirroring the movement-class stamp). Empty providers
54// have a null object identity and revision zero. A stateful provider must stay
55// at an address-stable location while the graph may be reused and expose
56// `std::uint64_t transition_revision() const noexcept` and change that value
57// whenever its emitted transition set can change. update_region_graph falls
58// back to a full rebuild when any stamp differs.
60template <typename P, typename World>
61concept TransitionProviderFor = requires(const P& provider, const World& world,
62 ChunkKey chunk,
63 void (*sink)(Coord3, Coord3)) {
64 provider.for_each_transition(world, chunk, sink);
65} && (std::is_empty_v<P> || requires(const P& provider) {
66 {
67 provider.transition_revision()
68 } noexcept -> std::same_as<std::uint64_t>;
69 });
70
72template <typename P, typename World>
74 requires(const P& provider, const World& world, Coord3 from,
75 void (*sink)(SpecialTransitionCandidate)) {
76 provider.for_each_forward(world, from, sink);
77 } &&
78 (std::is_empty_v<P> || requires(const P& provider) {
79 {
80 provider.transition_revision()
81 } noexcept -> std::same_as<std::uint64_t>;
82 });
83
85template <typename P, typename World>
88 requires(const P& provider, const World& world, Coord3 to,
89 void (*sink)(SpecialTransitionCandidate)) {
90 provider.for_each_reverse(world, to, sink);
91 };
92
93namespace detail {
94
95// Revision helper shared by graph construction and incremental updates.
96// Empty providers compile to the constant zero; stateful providers pay one
97// cheap revision read per graph build/update, never per transition.
98template <typename P>
99[[nodiscard]] constexpr auto transition_provider_revision(
100 const P& provider) noexcept -> std::uint64_t {
101 if constexpr (std::is_empty_v<P>) {
102 (void)provider;
103 return 0;
104 } else {
105 return provider.transition_revision();
106 }
107}
108
109// A stateful provider's address distinguishes two live instances whose local
110// revision counters happen to match. Empty providers have no instance state,
111// so nullptr preserves cache reuse across their temporary values. Retained
112// products/caches require a stateful provider to stay address-stable;
113// callers must clear them before ending that lifetime because placement-new
114// reuse could repeat both address and revision.
115template <typename P>
116[[nodiscard]] auto transition_provider_instance_identity(
117 const P& provider) noexcept -> const void* {
118 if constexpr (std::is_empty_v<P>) {
119 (void)provider;
120 return nullptr;
121 } else {
122 return static_cast<const void*>(std::addressof(provider));
123 }
124}
125
126} // namespace detail
127
128// The default provider: no transitions beyond the built-in face adjacency
129// the region graph already pairs. Building with it is byte-identical to the
130// providerless build.
133 static constexpr std::uint32_t maximum_transition_cost = 0;
134
135 template <typename World, typename Sink>
136 void for_each_transition(const World& /*world*/, ChunkKey /*chunk*/,
137 Sink&& /*sink*/) const noexcept {}
138
139 template <typename World, typename Sink>
140 void for_each_forward(const World&, Coord3, Sink&&) const noexcept {}
141
142 template <typename World, typename Sink>
143 void for_each_reverse(const World&, Coord3, Sink&&) const noexcept {}
144};
145
146// Direction a stair's landing lies in, stored as the integral value of the
147// stair field (0 keeps "no stair" as the zero-initialized default).
149enum class StairDirection : std::uint8_t {
150 None = 0,
151 PositiveX = 1,
152 NegativeX = 2,
153 PositiveY = 3,
154 NegativeY = 4,
155};
156
157// Vertical provider: an integral `StairTag` field holds a StairDirection.
158// A non-None value marks the tile as the FOOT of a stair whose landing is
159// one step in that direction AND one z-level up -- deliberately offset,
160// because two vertically stacked passable tiles are already six-axis
161// adjacent, so a same-column "stair" would add nothing. Each stair
162// contributes both directions, each emitted from the chunk owning its
163// origin tile: the up transition from the foot's chunk, the down transition
164// from the landing's chunk -- the foot's chunk, its +z face neighbor (the
165// landing rose off the top layer), or a sideways face neighbor (the landing
166// stepped off the x/y edge at a lower local z). Whether either endpoint is
167// traversable stays a movement-class question -- the graph builders drop
168// transitions whose endpoints hold no region label, so stair edges are
169// automatically per-class. A stair field value outside the StairDirection
170// range reads as None.
171//
172// Limit: a landing must share the foot's chunk or a face-neighbor chunk
173// (the TransitionProviderFor contract). A stair whose landing would cross
174// two chunk boundaries at once -- sideways off the chunk's x/y edge AND up
175// off its top z layer -- contributes nothing; place the foot so the landing
176// stays within face-neighbor range.
177//
178// CALLER OBLIGATION: writing this field does NOT make a built region graph
179// stale. Freshness compares chunk topology versions, and only
180// `mark_topology_dirty`/`mark_topology_rebuilt` advance one -- a raw field
181// write bumps nothing. A graph built before the edit keeps reporting fresh,
182// so `precheck_path` can return a definitive, wrong `Unreachable` for a
183// route the new stair just opened. After editing any field a movement class
184// or provider reads, mark the affected chunks topology-dirty and rebuild.
185// This provider reads only the emitting chunk's own field, so the owning
186// chunk plus its face neighbours suffices here; a provider that reads
187// outside the chunk it emits for must dirty every chunk whose transitions
188// the edit can change, carry a revision, or force a full rebuild (see
189// docs/architecture/topology.md). This provider cannot detect the edit for
190// you: it is an empty type, so its instance identity is always null and its
191// revision always zero, and both stamps compare equal across any edit.
193template <typename StairTag>
195 static constexpr std::uint32_t maximum_transition_cost = 1;
196
197 template <typename World, typename Sink>
198 void for_each_transition(const World& world, ChunkKey chunk,
199 Sink&& sink) const {
200 using Shape = typename World::shape_type;
201 using Traits = ShapeTraits<Shape>;
202 static_assert(
203 World::schema_type::template contains<StairTag>,
204 "StairTransitions references a field absent from the schema.");
205 emit_for_feet(world, chunk, chunk, sink);
206 // Down transitions originating here belong to stairs whose FOOT lies in
207 // a face-neighbor chunk: one z-level below (the landing rose off the
208 // foot chunk's top layer) or sideways at the same chunk z (the landing
209 // stepped off the foot chunk's x/y edge with local z below the top).
210 const auto coord_of_chunk = chunk_coord<Shape>(chunk);
211 const auto scan_foot_chunk = [&](ChunkCoord3 neighbor) {
212 const auto key = chunk_key<Shape>(neighbor);
213 if constexpr (std::is_same_v<typename World::residency_type,
215 // A non-resident foot chunk holds no readable stairs; the landing
216 // tile sits on this chunk's boundary toward it, whose boundary exit
217 // already marks the region as reaching missing topology.
218 if (!world.is_resident(key)) {
219 return;
220 }
221 }
222 emit_for_feet(world, key, chunk, sink);
223 };
224 if (coord_of_chunk.z > 0) {
225 auto below = coord_of_chunk;
226 --below.z;
227 scan_foot_chunk(below);
228 }
229 if (coord_of_chunk.x > 0) {
230 auto west = coord_of_chunk;
231 --west.x;
232 scan_foot_chunk(west);
233 }
234 if (coord_of_chunk.x + 1 < Traits::chunk_count_x) {
235 auto east = coord_of_chunk;
236 ++east.x;
237 scan_foot_chunk(east);
238 }
239 if (coord_of_chunk.y > 0) {
240 auto south = coord_of_chunk;
241 --south.y;
242 scan_foot_chunk(south);
243 }
244 if (coord_of_chunk.y + 1 < Traits::chunk_count_y) {
245 auto north = coord_of_chunk;
246 ++north.y;
247 scan_foot_chunk(north);
248 }
249 }
250
252 template <typename World, typename Sink>
253 void for_each_forward(const World& world, Coord3 from, Sink&& sink) const {
254 for_each_from(world, from, sink);
255 }
256
258 template <typename World, typename Sink>
259 void for_each_reverse(const World& world, Coord3 to, Sink&& sink) const {
260 // Built-in stairs are bidirectional, so their reverse adjacency is the
261 // same deterministic per-coordinate enumeration as forward adjacency.
262 for_each_from(world, to, sink);
263 }
264
265 private:
266 template <typename World>
267 [[nodiscard]] static auto try_landing(const World& world, Coord3 foot,
268 Coord3& landing) -> bool {
269 using Shape = typename World::shape_type;
270 if (!contains<Shape>(foot)) {
271 return false;
272 }
273 const auto resolved = world.resolve(foot);
274 const auto* page = world.try_chunk(resolved.chunk_key);
275 if (page == nullptr) {
276 return false;
277 }
278 const auto value = page->template field<StairTag>(resolved.local_tile_id);
279 static_assert(std::is_integral_v<std::remove_cvref_t<decltype(value)>>,
280 "StairTransitions requires an integral stair field.");
281 if constexpr (std::is_signed_v<std::remove_cvref_t<decltype(value)>>) {
282 if (value <= 0) {
283 return false;
284 }
285 } else if (value == 0) {
286 return false;
287 }
288 if (static_cast<std::uint64_t>(value) >
289 static_cast<std::uint64_t>(StairDirection::NegativeY)) {
290 return false;
291 }
292
293 landing = foot;
294 ++landing.z;
295 switch (static_cast<StairDirection>(static_cast<std::uint8_t>(value))) {
296 case StairDirection::PositiveX:
297 ++landing.x;
298 break;
299 case StairDirection::NegativeX:
300 --landing.x;
301 break;
302 case StairDirection::PositiveY:
303 ++landing.y;
304 break;
305 case StairDirection::NegativeY:
306 --landing.y;
307 break;
308 case StairDirection::None:
309 return false;
310 }
311 if (!contains<Shape>(landing)) {
312 return false;
313 }
314 const auto foot_chunk = chunk_coord<Shape>(foot);
315 const auto landing_chunk = chunk_coord<Shape>(landing);
316 const auto crossings = (foot_chunk.x != landing_chunk.x ? 1 : 0) +
317 (foot_chunk.y != landing_chunk.y ? 1 : 0) +
318 (foot_chunk.z != landing_chunk.z ? 1 : 0);
319 return crossings <= 1;
320 }
321
322 template <typename World, typename Sink>
323 static void for_each_from(const World& world, Coord3 from, Sink&& sink) {
324 using Shape = typename World::shape_type;
325 if (!contains<Shape>(from)) {
326 return;
327 }
328
329 auto landing = Coord3{};
330 if (try_landing(world, from, landing)) {
331 sink(SpecialTransitionCandidate{.to = landing});
332 }
333
334 if (from.z == 0) {
335 return;
336 }
337 const auto probe_foot = [&](Coord3 foot) {
338 if (!contains<Shape>(foot)) {
339 return;
340 }
341 const auto resolved = world.resolve(foot);
342 if (world.try_chunk(resolved.chunk_key) == nullptr) {
343 sink(SpecialTransitionCandidate{.to = foot, .missing_topology = true});
344 return;
345 }
346 auto candidate_landing = Coord3{};
347 if (try_landing(world, foot, candidate_landing) &&
348 candidate_landing == from) {
349 sink(SpecialTransitionCandidate{.to = foot});
350 }
351 };
352 probe_foot(Coord3{from.x - 1, from.y, from.z - 1});
353 probe_foot(Coord3{from.x + 1, from.y, from.z - 1});
354 probe_foot(Coord3{from.x, from.y - 1, from.z - 1});
355 probe_foot(Coord3{from.x, from.y + 1, from.z - 1});
356 }
357
358 // Emits the transitions of every stair FOOT in `foot_chunk` whose origin
359 // tile lies in `origin_chunk`: the up transition when the foot is the
360 // origin, the down transition when the landing is.
361 template <typename World, typename Sink>
362 static void emit_for_feet(const World& world, ChunkKey foot_chunk,
363 ChunkKey origin_chunk, Sink&& sink) {
364 using Shape = typename World::shape_type;
365 using Traits = ShapeTraits<Shape>;
366 const auto& page = world.chunk(foot_chunk);
367 const auto origin = chunk_coord<Shape>(foot_chunk);
368 const auto base = Coord3{
369 static_cast<std::int64_t>(origin.x * Traits::chunk.x),
370 static_cast<std::int64_t>(origin.y * Traits::chunk.y),
371 static_cast<std::int64_t>(origin.z * Traits::chunk.z),
372 };
373 for (std::uint64_t raw_id = 0; raw_id < Traits::local_tile_count;
374 ++raw_id) {
375 const auto value = page.template field<StairTag>(LocalTileId{raw_id});
376 static_assert(std::is_integral_v<std::remove_cvref_t<decltype(value)>>,
377 "StairTransitions requires an integral stair field.");
378 // Out-of-range values read as None. Compare BEFORE any narrowing: a
379 // wider field's 257 must not wrap into PositiveX, and a negative value
380 // must not wrap into the valid range.
381 if constexpr (std::is_signed_v<std::remove_cvref_t<decltype(value)>>) {
382 if (value <= 0) {
383 continue;
384 }
385 } else if (value == 0) {
386 continue;
387 }
388 if (static_cast<std::uint64_t>(value) >
389 static_cast<std::uint64_t>(StairDirection::NegativeY)) {
390 continue;
391 }
392 const auto direction =
393 static_cast<StairDirection>(static_cast<std::uint8_t>(value));
394 const auto local = local_coord_of<Shape>(raw_id);
395 const auto foot =
396 Coord3{base.x + local.x, base.y + local.y, base.z + local.z};
397 auto landing = foot;
398 ++landing.z;
399 switch (direction) {
400 case StairDirection::PositiveX:
401 ++landing.x;
402 break;
403 case StairDirection::NegativeX:
404 --landing.x;
405 break;
406 case StairDirection::PositiveY:
407 ++landing.y;
408 break;
409 case StairDirection::NegativeY:
410 --landing.y;
411 break;
412 case StairDirection::None:
413 continue;
414 }
415 if (!contains<Shape>(landing)) {
416 continue;
417 }
418 // Diagonal chunk crossings (sideways AND up at once) violate the
419 // face-neighbor contract; such a stair contributes nothing.
420 const auto foot_chunk_coord = chunk_coord<Shape>(foot);
421 const auto landing_chunk_coord = chunk_coord<Shape>(landing);
422 const auto dx = foot_chunk_coord.x != landing_chunk_coord.x ? 1 : 0;
423 const auto dy = foot_chunk_coord.y != landing_chunk_coord.y ? 1 : 0;
424 const auto dz = foot_chunk_coord.z != landing_chunk_coord.z ? 1 : 0;
425 if (dx + dy + dz > 1) {
426 continue;
427 }
428 const auto landing_chunk = chunk_key<Shape>(landing_chunk_coord);
429 if (foot_chunk.value == origin_chunk.value) {
430 sink(foot, landing); // up, originating at the foot
431 if (landing_chunk.value == origin_chunk.value) {
432 sink(landing, foot); // down, landing shares the chunk
433 }
434 } else if (landing_chunk.value == origin_chunk.value) {
435 sink(landing, foot); // down, originating at the landing
436 }
437 }
438 }
439
440 template <typename Shape>
441 [[nodiscard]] static auto local_coord_of(std::uint64_t raw_id) noexcept
442 -> Coord3 {
443 const auto chunk = ShapeTraits<Shape>::chunk;
444 const auto xy = static_cast<std::uint64_t>(chunk.x) * chunk.y;
445 const auto z = raw_id / xy;
446 const auto rest = raw_id % xy;
447 return Coord3{
448 static_cast<std::int64_t>(rest % chunk.x),
449 static_cast<std::int64_t>(rest / chunk.x),
450 static_cast<std::int64_t>(z),
451 };
452 }
453};
454
455} // namespace tess
Definition world.h:22
Definition transition_provider.h:73
Definition transition_provider.h:86
Constrains deterministic special-transition providers for a world type.
Definition transition_provider.h:61
Supplies no special transitions beyond ordinary face adjacency.
Definition transition_provider.h:132
Definition shape.h:58
Definition shape.h:86
Definition shape.h:46
Definition shape.h:320
Definition shape.h:296
Definition residency.h:18
Definition transition_provider.h:14
Emits bidirectional stair transitions encoded by an integral world field.
Definition transition_provider.h:194
void for_each_forward(const World &world, Coord3 from, Sink &&sink) const
Definition transition_provider.h:253
void for_each_reverse(const World &world, Coord3 to, Sink &&sink) const
Definition transition_provider.h:259