tess 0.4.0
Performance-first tile and path simulation substrate
Loading...
Searching...
No Matches
adapter.h
1#pragma once
2
3#include <tess/core/assert.h>
4#include <tess/ecs/entity_handle.h>
5#include <tess/sim/delta_frame.h>
6#include <tess/sim/path_agent.h>
7#include <tess/sim/path_agent_tick.h>
8
9#include <concepts>
10#include <cstddef>
11#include <cstdint>
12#include <span>
13#include <vector>
14
15// The ECS-agnostic integration layer (M10). Everything here is free of
16// third-party dependencies: concrete ECS adapters (the EnTT adapter in
17// <tess/ecs/entt/entt_adapter.h>, or a game's own) implement the concepts
18// below and reuse the shared components, batch scratch, occupancy index,
19// and tick pipeline. The seam is deliberately "agents in deterministic
20// order in, state write-back out" -- request submission, tickets, retry
21// budgets, and exactly-once result application all stay inside the
22// PathAgentState lifecycle (sim/path_agent.h), so adapters can never
23// duplicate or violate it.
24namespace tess {
25
26// EntityHandle and kNullEntityHandle live in <tess/ecs/entity_handle.h>
27// (re-exported here) so dependency-light layers can name entity identity.
28
35template <typename A>
36concept EntityHandleAdapter = requires(const A& adapter, EntityHandle handle,
37 const typename A::entity_type& entity) {
38 typename A::entity_type;
39 { adapter.to_handle(entity) } noexcept -> std::same_as<EntityHandle>;
40 {
41 adapter.to_entity(handle)
42 } noexcept -> std::same_as<typename A::entity_type>;
43};
44
46template <typename A, typename Entity>
47concept PositionAdapter = requires(A& adapter, const A& const_adapter,
48 const Entity& entity, Coord3 coord) {
49 { const_adapter.position(entity) } -> std::convertible_to<Coord3>;
50 adapter.set_position(entity, coord);
51};
52
55 std::size_t count = 0;
56 // True iff any goal was armed or re-armed during collection; the tick
57 // pipeline maps it to mark_pathing_dirty. Goal clears do not set it: a
58 // cleared agent is skipped by every processing pass, so re-pathing the
59 // survivors would be pure waste.
60 bool pathing_dirty = false;
61};
62
70 public:
71 void reserve(std::size_t agent_capacity) {
72 handles_.reserve(agent_capacity);
73 agents_.reserve(agent_capacity);
74 }
75
76 void clear() noexcept {
77 handles_.clear();
78 agents_.clear();
79 }
80
81 void push(EntityHandle handle, const PathAgentState& agent) {
82 handles_.push_back(handle);
83 agents_.push_back(agent);
84 }
85
86 [[nodiscard]] auto size() const noexcept -> std::size_t {
87 return agents_.size();
88 }
89
90 [[nodiscard]] auto agents() noexcept -> std::span<PathAgentState> {
91 return agents_;
92 }
93
94 [[nodiscard]] auto agents() const noexcept
95 -> std::span<const PathAgentState> {
96 return agents_;
97 }
98
99 [[nodiscard]] auto handles() const noexcept -> std::span<const EntityHandle> {
100 return handles_;
101 }
102
103 private:
104 std::vector<EntityHandle> handles_;
105 std::vector<PathAgentState> agents_;
106};
107
114template <typename S>
115concept PathAgentSource = requires(S& source, PathAgentBatch& batch) {
116 { source.collect(batch) } -> std::same_as<PathAgentCollectInfo>;
117};
118
125template <typename S>
127 requires(S& sink, const PathAgentBatch& batch) { sink.apply(batch); };
128
129// Shared components for ECS-side agents. Plain PODs with no dependency on
130// any ECS library, so every adapter (and any game-defined store) can reuse
131// them.
132
139struct AgentId {
140 std::uint64_t value = 0;
141};
142
145 Coord3 coord{};
146};
147
149struct PathGoal {
150 Coord3 coord{};
151};
152
159struct PathState {
160 PathAgentState agent{};
161};
162
168struct OffBoard {};
169
178 public:
179 // Sizes the table so `entity_capacity` entries fit without rehashing.
180 void reserve(std::size_t entity_capacity) {
181 auto target = std::size_t{8};
182 while (target < entity_capacity * 2) {
183 target *= 2;
184 }
185 if (target > slots_.size()) {
186 rehash(target);
187 }
188 }
189
190 // Maps `tile` to `entity`. Returns false (and mutates nothing) if the
191 // tile already maps to a DIFFERENT entity -- occupancy uniqueness is
192 // structural, not advisory. Re-inserting the same mapping succeeds.
193 // A refusal is not allocation-free at the growth threshold: the table
194 // rehashes before discovering the duplicate tile.
195 [[nodiscard]] auto insert(Coord3 tile, EntityHandle entity) -> bool {
196 TESS_ASSERT_MSG(!entity.is_null(),
197 "TileOccupancyIndex cannot map a null entity");
198 // probe_start's fast lane combine relies on this domain; see its
199 // comment.
200 TESS_ASSERT_MSG(tile.x >= 0 && tile.y >= 0 && tile.z >= 0,
201 "TileOccupancyIndex stores world tiles, which are "
202 "non-negative");
203 if (slots_.empty() || (size_ + 1) * 2 > slots_.size()) {
204 rehash(slots_.empty() ? 8 : slots_.size() * 2);
205 }
206 auto index = probe_start(tile);
207 for (;;) {
208 auto& slot = slots_[index];
209 if (slot.entity.is_null()) {
210 slot = Slot{tile, entity};
211 ++size_;
212 return true;
213 }
214 if (slot.tile == tile) {
215 return slot.entity == entity;
216 }
217 index = (index + 1) & mask();
218 }
219 }
220
221 // Unmaps `tile`, returning the entity it held (null if it held none).
222 auto erase(Coord3 tile) noexcept -> EntityHandle {
223 if (slots_.empty()) {
224 return kNullEntityHandle;
225 }
226 auto index = probe_start(tile);
227 for (;;) {
228 auto& slot = slots_[index];
229 if (slot.entity.is_null()) {
230 return kNullEntityHandle;
231 }
232 if (slot.tile == tile) {
233 break;
234 }
235 index = (index + 1) & mask();
236 }
237 const auto erased = slots_[index].entity;
238 // Backward-shift deletion: pull every trailing probe-chain entry
239 // whose ideal slot lies cyclically at or before the hole into the
240 // hole, so no lookup's probe path is ever severed.
241 auto hole = index;
242 auto next = index;
243 for (;;) {
244 next = (next + 1) & mask();
245 const auto& candidate = slots_[next];
246 if (candidate.entity.is_null()) {
247 break;
248 }
249 const auto ideal = probe_start(candidate.tile);
250 const auto in_gap = (next > hole) ? (ideal > hole && ideal <= next)
251 : (ideal > hole || ideal <= next);
252 if (!in_gap) {
253 slots_[hole] = candidate;
254 hole = next;
255 }
256 }
257 slots_[hole] = Slot{};
258 --size_;
259 return erased;
260 }
261
262 // erase(from) + insert(to, entity) as the movement-commit hot path,
263 // with debug asserts that `from` held `entity` and `to` was empty.
264 // Never rehashes: the net size is unchanged.
265 void move(Coord3 from, Coord3 to, EntityHandle entity) noexcept {
266 // Same pinned domain as insert(): move() is the second write path
267 // into the table, and probe_start's fast lane combine relies on it.
268 TESS_ASSERT_MSG(to.x >= 0 && to.y >= 0 && to.z >= 0,
269 "TileOccupancyIndex stores world tiles, which are "
270 "non-negative");
271 const auto erased = erase(from);
272 TESS_ASSERT_MSG(erased == entity,
273 "TileOccupancyIndex::move source held another entity");
274 static_cast<void>(erased);
275 TESS_ASSERT_MSG(entity_at(to).is_null(),
276 "TileOccupancyIndex::move destination already mapped");
277 auto index = probe_start(to);
278 while (!slots_[index].entity.is_null()) {
279 index = (index + 1) & mask();
280 }
281 slots_[index] = Slot{to, entity};
282 ++size_;
283 }
284
285 [[nodiscard]] auto entity_at(Coord3 tile) const noexcept -> EntityHandle {
286 if (slots_.empty()) {
287 return kNullEntityHandle;
288 }
289 auto index = probe_start(tile);
290 for (;;) {
291 const auto& slot = slots_[index];
292 if (slot.entity.is_null()) {
293 return kNullEntityHandle;
294 }
295 if (slot.tile == tile) {
296 return slot.entity;
297 }
298 index = (index + 1) & mask();
299 }
300 }
301
302 [[nodiscard]] auto size() const noexcept -> std::size_t { return size_; }
303
304 void clear() noexcept {
305 for (auto& slot : slots_) {
306 slot = Slot{};
307 }
308 size_ = 0;
309 }
310
311 private:
312 struct Slot {
313 Coord3 tile{};
314 EntityHandle entity = kNullEntityHandle;
315 };
316
317 [[nodiscard]] auto mask() const noexcept -> std::size_t {
318 return slots_.size() - 1;
319 }
320
321 [[nodiscard]] static auto mix(std::uint64_t value) noexcept -> std::uint64_t {
322 value += 0x9E3779B97F4A7C15ULL;
323 value = (value ^ (value >> 30U)) * 0xBF58476D1CE4E5B9ULL;
324 value = (value ^ (value >> 27U)) * 0x94D049BB133111EBULL;
325 return value ^ (value >> 31U);
326 }
327
328 [[nodiscard]] auto probe_start(Coord3 tile) const noexcept -> std::size_t {
329 // One avalanche over per-lane multiplies instead of three chained
330 // mix() rounds (6 serial multiplies): the lanes now hash in parallel
331 // and erase's backward-shift, which recomputes probe_start per
332 // displaced entry, pays one round (audit 2026-07-11 low).
333 //
334 // The XOR combine has sign/lane-swap symmetries (Codex review:
335 // (-n, n, 0) collides with (n, -n, 0)), but those inputs are out of
336 // domain -- insert() asserts non-negative world coordinates, and the
337 // symmetry needs a negative lane. Additive and rotated combines were
338 // measured 1.7-1.9x slower here (interleaved A/B), so the domain is
339 // pinned instead of the hash hardened.
340 const auto hash =
341 mix(static_cast<std::uint64_t>(tile.x) * 0x9E3779B97F4A7C15ULL ^
342 static_cast<std::uint64_t>(tile.y) * 0xC2B2AE3D27D4EB4FULL ^
343 static_cast<std::uint64_t>(tile.z) * 0x165667B19E3779F9ULL);
344 return static_cast<std::size_t>(hash) & mask();
345 }
346
347 void rehash(std::size_t new_capacity) {
348 auto old = std::vector<Slot>(new_capacity);
349 old.swap(slots_);
350 size_ = 0;
351 for (const auto& slot : old) {
352 if (!slot.entity.is_null()) {
353 auto index = probe_start(slot.tile);
354 while (!slots_[index].entity.is_null()) {
355 index = (index + 1) & mask();
356 }
357 slots_[index] = slot;
358 ++size_;
359 }
360 }
361 }
362
363 std::vector<Slot> slots_;
364 std::size_t size_ = 0;
365};
366
367template <typename World, typename ClassOrTag, typename OccupancyTag,
368 typename ReservationTag>
375inline auto advance_path_agents_with_index(
376 World& world, PathAgentBatch& batch, const PathRequestRuntime& runtime,
377 TileOccupancyIndex& index, std::size_t max_steps = 1,
378 std::uint32_t movement_dirty_mask = 0,
379 DeltaCollector* render_deltas = nullptr) -> PathAgentFrameStats {
380 const auto handles = batch.handles();
381 return advance_path_agents_with_movement<World, ClassOrTag, OccupancyTag,
382 ReservationTag>(
383 world, batch.agents(), runtime, max_steps, movement_dirty_mask,
384 [&index, handles, render_deltas](std::size_t agent_index, Coord3 from,
385 Coord3 to) {
386 index.move(from, to, handles[agent_index]);
387 if (render_deltas != nullptr) {
388 render_deltas->record_move(handles[agent_index], from, to);
389 }
390 });
391}
392
393template <typename World, typename ClassOrTag, typename OccupancyTag,
394 typename ReservationTag, PathAgentSource Source, PathAgentSink Sink>
402[[nodiscard]] auto tick_ecs_unit_path_agents(
403 PathAgentTickState& state, World& world, Source& source, Sink& sink,
404 PathAgentBatch& batch, PathRequestRuntime& runtime,
405 TileOccupancyIndex& index, PathAgentTickOptions options = {},
406 std::uint32_t movement_dirty_mask = 0,
408 DeltaCollector* render_deltas = nullptr) -> PathAgentTickStats {
409 const auto info = source.collect(batch);
410 if (info.pathing_dirty) {
411 mark_pathing_dirty(state);
412 }
413
414 PathAgentTickStats stats;
415 stats.tick = advance_sim_tick(state.clock);
416 if (render_deltas != nullptr) {
417 // Stamp every commit this tick before movement runs.
418 render_deltas->begin_tick(stats.tick);
419 }
420
421 const bool repath_needed =
422 prepare_path_agent_processing(batch.agents(), options, stats);
423 if (state.pathing_dirty || repath_needed) {
424 stats.pathing = process_unit_path_agents<World, ClassOrTag>(
425 world, batch.agents(), runtime, options.cache_policy, graph);
426 stats.processed_paths = true;
427 state.pathing_dirty = false;
428 }
429
430 stats.movement = advance_path_agents_with_index<World, ClassOrTag,
431 OccupancyTag, ReservationTag>(
432 world, batch, runtime, index, options.max_steps, movement_dirty_mask,
433 render_deltas);
434 sink.apply(batch);
435 return stats;
436}
437
438template <typename World, typename Class, std::uint32_t MaxCost,
439 typename OccupancyTag, typename ReservationTag,
440 PathAgentSource Source, PathAgentSink Sink>
447[[nodiscard]] auto tick_ecs_path_agents(
448 PathAgentTickState& state, World& world, Source& source, Sink& sink,
449 PathAgentBatch& batch, PathRequestRuntime& runtime,
450 TileOccupancyIndex& index, PathAgentTickOptions options = {},
451 std::uint32_t movement_dirty_mask = 0,
453 DeltaCollector* render_deltas = nullptr) -> PathAgentTickStats {
454 const auto info = source.collect(batch);
455 if (info.pathing_dirty) {
456 mark_pathing_dirty(state);
457 }
458
459 PathAgentTickStats stats;
460 stats.tick = advance_sim_tick(state.clock);
461 if (render_deltas != nullptr) {
462 // Stamp every commit this tick before movement runs.
463 render_deltas->begin_tick(stats.tick);
464 }
465
466 const bool repath_needed =
467 prepare_path_agent_processing(batch.agents(), options, stats);
468 if (state.pathing_dirty || repath_needed) {
469 stats.pathing = process_weighted_path_agents<World, Class, MaxCost>(
470 world, batch.agents(), runtime, options.cache_policy, graph);
471 stats.processed_paths = true;
472 state.pathing_dirty = false;
473 }
474
475 stats.movement = advance_path_agents_with_index<World, Class, OccupancyTag,
476 ReservationTag>(
477 world, batch, runtime, index, options.max_steps, movement_dirty_mask,
478 render_deltas);
479 sink.apply(batch);
480 return stats;
481}
482
483template <typename World, typename PassableTag, typename CostTag,
484 std::uint32_t MaxCost, typename OccupancyTag, typename ReservationTag,
485 PathAgentSource Source, PathAgentSink Sink>
492[[nodiscard]] auto tick_ecs_path_agents(
493 PathAgentTickState& state, World& world, Source& source, Sink& sink,
494 PathAgentBatch& batch, PathRequestRuntime& runtime,
495 TileOccupancyIndex& index, PathAgentTickOptions options = {},
496 std::uint32_t movement_dirty_mask = 0,
498 DeltaCollector* render_deltas = nullptr) -> PathAgentTickStats {
499 const auto info = source.collect(batch);
500 if (info.pathing_dirty) {
501 mark_pathing_dirty(state);
502 }
503
504 PathAgentTickStats stats;
505 stats.tick = advance_sim_tick(state.clock);
506 if (render_deltas != nullptr) {
507 // Stamp every commit this tick before movement runs.
508 render_deltas->begin_tick(stats.tick);
509 }
510
511 const bool repath_needed =
512 prepare_path_agent_processing(batch.agents(), options, stats);
513 if (state.pathing_dirty || repath_needed) {
514 stats.pathing =
515 process_weighted_path_agents<World, PassableTag, CostTag, MaxCost>(
516 world, batch.agents(), runtime, options.cache_policy, graph);
517 stats.processed_paths = true;
518 state.pathing_dirty = false;
519 }
520
521 stats.movement = advance_path_agents_with_index<World, PassableTag,
522 OccupancyTag, ReservationTag>(
523 world, batch, runtime, index, options.max_steps, movement_dirty_mask,
524 render_deltas);
525 sink.apply(batch);
526 return stats;
527}
528
529} // namespace tess
Definition delta_frame.h:226
Definition adapter.h:69
Definition path_runtime.h:92
Region graph storage specialized by dense or sparse residency policy.
Definition topology.h:309
Definition adapter.h:177
Definition world.h:22
Definition adapter.h:36
Definition adapter.h:126
Definition adapter.h:115
Definition adapter.h:47
Definition adapter.h:139
Definition shape.h:30
Definition entity_handle.h:16
Definition adapter.h:168
Definition adapter.h:54
Summarizes path submission, results, movement, and failure outcomes.
Definition path_agent.h:44
Stores one agent's goal, route cursor, and retry lifecycle state.
Definition path_agent.h:32
Configures per-tick movement, caching, and blocked-agent retry limits.
Definition path_agent_tick.h:29
Owns the shared clock, world-dirty flag, and retained routes across ticks.
Definition path_agent_tick.h:13
Summarizes path planning and movement performed during one tick.
Definition path_agent_tick.h:46
Definition adapter.h:149
Definition adapter.h:159
Definition adapter.h:144