diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e1287d2..e7ea22f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -30,14 +30,18 @@ jobs: - name: Compile controller for wasm32 run: zig build wasm-check - # Prepared endpoint integration depends on engine#916 until its release. + # Prepared endpoint integration depends on engine#917 until its release. # Pin the exact tested commit rather than following an unreleased branch. - name: Checkout engine position revision API run: | git init ../labelle-engine git -C ../labelle-engine remote add origin https://github.com/labelle-toolkit/labelle-engine.git - git -C ../labelle-engine fetch --depth 1 origin b93dafd1295fe5324be70c6249fc4687a78b8fe3 + git -C ../labelle-engine fetch --depth 1 origin 21ea60c67038416c007e308814c137358104353d git -C ../labelle-engine checkout --detach FETCH_HEAD - name: Run engine integration tests run: zig build test --build-file tests/integration/build.zig --summary all + + - name: Compile serial and threaded WASM snapshot executor + working-directory: ../labelle-engine + run: zig build snapshot-worker-check diff --git a/build.zig.zon b/build.zig.zon index 0f42101..03f0a74 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -3,7 +3,7 @@ // v3.0.0 — repurposed: the ECS navigation Controller (promoted from // flying-platform) is now the headline API; the standalone algorithm core // moves under `.algo`; the old position-owning engine is dropped. - .version = "4.2.0", + .version = "4.3.0-rc.2", .fingerprint = 0x15f62ccc39d14685, .minimum_zig_version = "0.16.0", .dependencies = .{ diff --git a/rfc/prepared-endpoint-implementation.md b/rfc/prepared-endpoint-implementation.md index 1d9b570..3edb4b1 100644 --- a/rfc/prepared-endpoint-implementation.md +++ b/rfc/prepared-endpoint-implementation.md @@ -148,3 +148,32 @@ mutation and participant audits, generation-qualified graph/query freshness, remaining CMN writers/known-node updates, frame-order placement, game query migration and normal-retry handling for active jobs/raids. In particular, this phase's graph checks do not constitute a query-time guarantee after arbitrary later mutations. + +## Prepared queries and graph dependency freshness + +`preparedDistance(game, from, to)` reads only current published endpoints and the +existing FW table. Ordinary entities require a ready CMN with matching world, +entity, position and graph stamps. MovementNode endpoints use the current live +entity registration directly, without a self-CMN. A stale/absent/off-graph or +disconnected endpoint, dirty table, or changed graph dependency returns null. +There are no allocations, parent walks, nearest-node scans, repairs or Euclidean +fallbacks in this query. + +Graph construction watches MovementNode/MovementStair/MovementLink mutations and +registered node positions through the engine dependency group. Any subsequent +watched mutation invalidates all prepared queries immediately. Full graph input +validation remains in advance/preparation, outside queries. Direct backend writes +must use the engine invalidation companion; command-level node removal also +invalidates the table. Component-pointer writes are not magically observable. + +`endpointPreparationComplete` is a separate lifecycle gate: pending/stale is +false, completed off-graph is true. It does not change the matching null contract. +It lets an active job or raid retry preparation before concluding that no usable +candidate exists. Destroyed targets still need the consumer's existing lifetime +policy. + +`publishKnownEndpoint` supports explicit docking/placement. It validates actual +world-space position against the registered node, snap radius and Y filter, then +publishes the current stamp. It rejects MovementNode recipients and cannot repair +a stale graph. Opted-in `requestRepath` invalidates for the bounded phase instead +of scanning synchronously. Legacy navigation commands remain available separately. diff --git a/src/nav/components.zig b/src/nav/components.zig index 9603ec9..32e384e 100644 --- a/src/nav/components.zig +++ b/src/nav/components.zig @@ -51,6 +51,7 @@ pub const ClosestMovementNode = struct { entity_id: u64 = 0, entity_generation: u64 = 0, position_revision: u64 = 0, + position_slot: u32 = @import("std").math.maxInt(u32), graph_revision: u64 = 0, pub fn valid(self: Stamp) bool { @@ -59,16 +60,26 @@ pub const ClosestMovementNode = struct { pub fn eql(a: Stamp, b: Stamp) bool { return a.world_revision == b.world_revision and a.entity_id == b.entity_id and a.entity_generation == b.entity_generation and - a.position_revision == b.position_revision and a.graph_revision == b.graph_revision; + a.position_revision == b.position_revision and a.position_slot == b.position_slot and a.graph_revision == b.graph_revision; } }; pub const Status = enum { pending, ready, off_graph }; pub const Invalidation = enum { none, position, ancestor, parent, graph, world }; - pub const Resolution = struct { node_entity: u64, node_id: u32, distance: f32 }; + /// Conservative nearest-node certificate, created only by full preparation. + /// It stays centred at the original sample even across repeated reuse. + pub const ReuseRegion = struct { + x: f32 = 0, + y: f32 = 0, + radius: f64 = 0, + min_y: f64 = 0, + max_y: f64 = 0, + }; + pub const Resolution = struct { node_entity: u64, node_id: u32, distance: f32, reuse: ReuseRegion = .{} }; node_entity: u64 = 0, node_id: u32 = 0, distance: f32 = 0.0, + reuse: ReuseRegion = .{}, status: Status = .pending, stamp: Stamp = .{}, invalidation: Invalidation = .none, @@ -99,6 +110,7 @@ pub const ClosestMovementNode = struct { self.node_entity = r.node_entity; self.node_id = r.node_id; self.distance = r.distance; + self.reuse = r.reuse; self.last_ready_stamp = snapshot; } self.stamp = snapshot; diff --git a/src/nav/controller.zig b/src/nav/controller.zig index 452a02a..d77aa19 100644 --- a/src/nav/controller.zig +++ b/src/nav/controller.zig @@ -187,9 +187,12 @@ pub const ControllerState = struct { pub const PreparationStats = struct { discovered: usize = 0, + domain_discovered: usize = 0, pending: usize = 0, selected: usize = 0, nearest_searches: usize = 0, + reused_endpoints: usize = 0, + worker_executed: bool = false, published: usize = 0, off_graph: usize = 0, discarded: usize = 0, @@ -201,7 +204,9 @@ const GraphInput = struct { stair: bool, link: ?MovementLink, }; +const SearchNode = struct { id: NodeId, entity: u64, position: Position }; const EndpointSnapshot = struct { + search: bool = true, entity: u64, stamp: ClosestMovementNode.Stamp, position: Position, @@ -226,11 +231,19 @@ pub const State = struct { pf: Pf, preparation_world: u64, preparation_enabled: bool = false, + preparation_worker: bool = false, + search_nodes: std.ArrayListUnmanaged(SearchNode) = .empty, + search_epoch: u64 = 0, + domain_revision: ?u64 = null, + domain_graph_epoch: u64 = 0, + domain_watches_ready: bool = false, + graph_dependency_revision: ?u64 = null, preparation_in_flight: bool = false, preparation_stats: PreparationStats = .{}, preparation_batch: @import("preparation_batch.zig").PreparationBatch = .{}, preparation_snapshots: std.ArrayListUnmanaged(EndpointSnapshot) = .empty, graph_inputs: std.AutoHashMapUnmanaged(u32, GraphInput) = .empty, + registered_entities: std.ArrayListUnmanaged(?u64) = .empty, allocator: std.mem.Allocator, /// Node-ID → entity lookup. Iteration order is deterministic /// (insertion order) so simulation replay is stable. @@ -316,7 +329,9 @@ pub const State = struct { pub fn deinit(self: *State) void { self.preparation_batch.deinit(self.allocator); self.preparation_snapshots.deinit(self.allocator); + self.search_nodes.deinit(self.allocator); self.graph_inputs.deinit(self.allocator); + self.registered_entities.deinit(self.allocator); self.nearest_node_cache.deinit(); self.node_entities.deinit(self.allocator); self.pending_entities.deinit(self.allocator); @@ -327,6 +342,7 @@ pub const State = struct { fn reset(self: *State, allocator: std.mem.Allocator) void { self.graph_inputs.clearRetainingCapacity(); + self.registered_entities.clearRetainingCapacity(); self.pf.deinit(); self.node_entities.deinit(self.allocator); self.nearest_node_cache.deinit(); @@ -773,6 +789,225 @@ pub const Controller = struct { return st.graph_epoch; } + /// Pure prepared matching: no world-position reads, nearest-node search, + /// allocation, table rebuild, or fallback. All nonmatches return null. + pub fn preparedDistance(game: anytype, from_entity: anytype, to_entity: anytype) ?f32 { + const st = findState(game) orelse return null; + if (!preparedGraphCurrent(game, st)) return null; + const from = preparedEndpointNode(game, st, from_entity) orelse return null; + const to = preparedEndpointNode(game, st, to_entity) orelse return null; + return st.pf.preparedNodeDistance(from, to); + } + + /// Reuse a validated origin across a destination scan; every lookup still + /// rejects changes to the origin, world, graph, or destination. + pub fn preparedDistancesFrom(game: anytype, entity: anytype) ?PreparedDistances(@TypeOf(game), @TypeOf(entity)) { + const st = findState(game) orelse return null; + if (!preparedGraphCurrent(game, st)) return null; + const node = preparedEndpointNode(game, st, entity) orelse return null; + const stamp = if (game.active_world.ecs_backend.hasComponent(entity, MovementNode)) null else game.active_world.ecs_backend.getComponent(entity, ClosestMovementNode).?.stamp; + return .{ .game = game, .entity = entity, .node = node, .world = st.preparation_world, .epoch = st.graph_epoch, .stamp = stamp }; + } + + pub fn PreparedDistances(comptime GamePtr: type, comptime Entity: type) type { + return struct { + game: GamePtr, + entity: Entity, + node: NodeId, + world: u64, + epoch: u64, + stamp: ?ClosestMovementNode.Stamp, + pub fn to(self: *const @This(), target: anytype) ?f32 { + const st = findState(self.game) orelse return null; + if (st.preparation_world != self.world or st.graph_epoch != self.epoch or !preparedGraphCurrent(self.game, st)) return null; + if (self.stamp) |stamp| { + const cmn = self.game.active_world.ecs_backend.getComponent(self.entity, ClosestMovementNode) orelse return null; + if (cmn.node_id != self.node or !cmn.ready(stamp) or self.game.positionRevisionAt(self.entity, stamp.position_slot) != stamp.position_revision) return null; + if (!registeredIdentity(st, self.node, cmn.node_entity)) return null; + } else if (!registeredIdentity(st, self.node, @intCast(self.entity))) return null; + const target_node = preparedEndpointNode(self.game, st, target) orelse return null; + return st.pf.preparedNodeDistance(self.node, target_node); + } + }; + } + + /// Optional shared worker execution. Serial remains the default. A supported + /// host may run one half on its persistent worker and one on the main thread; + /// every result is joined before prepare returns and remains unpublished. + pub fn setPreparationWorker(game: anytype, enabled: bool) void { + if (findState(game)) |st| st.preparation_worker = enabled; + } + + /// Publish a known placement (for example a ship's docking node). This + /// command validates the actual world-space position; it never scans for + /// another node and never attaches a CMN to a MovementNode. + pub fn publishKnownEndpoint(game: anytype, entity: anytype, node_entity: anytype) !bool { + const st = findState(game) orelse return false; + if (!preparedGraphCurrent(game, st)) return false; + const ecs = &game.active_world.ecs_backend; + if (!ecs.entityExists(entity) or ecs.hasComponent(entity, MovementNode)) return false; + const node = registeredNode(game, st, node_entity) orelse return false; + const pos = game.getWorldPosition(entity); + const anchor = st.pf.graph.getPosition(node); + const dx = pos.x - anchor.x; + const dy = pos.y - anchor.y; + const distance_to_node = @sqrt(dx * dx + dy * dy); + if (!std.math.isFinite(distance_to_node) or distance_to_node > MAX_NODE_SNAP_DISTANCE or anchor.y > pos.y + Y_FILTER_EPS) return false; + return publishEndpointAt(game, st, entity, node, @intCast(node_entity), distance_to_node); + } + + fn ensureSearchNodes(st: *State) !void { + if (st.search_epoch != st.graph_epoch) { + st.search_nodes.clearRetainingCapacity(); + try st.search_nodes.ensureTotalCapacity(st.allocator, st.pf.graph.totalSlots()); + for (0..st.pf.graph.totalSlots()) |i| { + const id: NodeId = @intCast(i); + if (st.pf.graph.isRemoved(id)) continue; + st.search_nodes.appendAssumeCapacity(.{ .id = id, .entity = st.node_entities.get(id) orelse continue, .position = st.pf.graph.getPosition(id) }); + } + st.search_epoch = st.graph_epoch; + } + } + + // Direct movers have no graph edge to own. Reuse a proven nearest-node + // region, or resolve again at the new position before publishing. + fn publishDirectEndpoint(game: anytype, st: *State, entity: anytype) !void { + if (!preparedGraphCurrent(game, st)) return; + const ecs = &game.active_world.ecs_backend; + _ = try game.trackPositionRevision(entity); + const stamp = endpointStamp(game, st, entity) orelse return; + var cmn = if (ecs.getComponent(entity, ClosestMovementNode)) |old| old.* else ClosestMovementNode{}; + const pos = game.getWorldPosition(entity); + var resolution = reusePreparedResolution(st, &cmn, stamp, pos); + if (resolution == null) { + try ensureSearchNodes(st); + resolution = resolvePreparedNearest(st.search_nodes.items, pos); + } + if (!cmn.publish(stamp, stamp, resolution)) return; + ecs.addComponent(entity, cmn); + game.active_world.position_revisions.acknowledge(@intCast(entity), stamp.position_revision); + if (resolution) |ready| publishAttachedEndpoints(game, entity, @as(@TypeOf(entity), @intCast(ready.node_entity))); + } + + fn publishEndpointAt(game: anytype, st: *State, entity: anytype, node: NodeId, node_entity: u64, distance_to_node: f32) !bool { + const ecs = &game.active_world.ecs_backend; + _ = try game.trackPositionRevision(entity); + const stamp = endpointStamp(game, st, entity) orelse return false; + var cmn = if (ecs.getComponent(entity, ClosestMovementNode)) |old| old.* else ClosestMovementNode{}; + if (!cmn.publish(stamp, stamp, .{ .node_entity = node_entity, .node_id = node, .distance = distance_to_node })) return false; + ecs.addComponent(entity, cmn); + game.active_world.position_revisions.acknowledge(@intCast(entity), stamp.position_revision); + return true; + } + + /// A graph walker owns its current edge. Resolve only its two endpoints, + /// never the whole graph. A point on a long authored edge is on-graph even + /// beyond the off-route snap radius. External movement fails this geometric + /// check and leaves the entity pending for ordinary preparation. + fn publishWalkingEndpoint(game: anytype, st: *State, entity: anytype, path: *const movement_path.MovementPath) void { + if (!st.preparation_enabled or !preparedGraphCurrent(game, st) or path.len == 0) return; + const idx = @min(path.current_index, path.len - 1); + const prev = if (idx > 0) idx - 1 else idx; + const a = path.positions[prev]; + const b = path.positions[idx]; + const pos = game.getWorldPosition(entity); + const dx = b.x - a.x; + const dy = b.y - a.y; + const length2 = dx * dx + dy * dy; + const t = if (length2 > 0) ((pos.x - a.x) * dx + (pos.y - a.y) * dy) / length2 else 0; + const px = a.x + std.math.clamp(t, 0, 1) * dx; + const py = a.y + std.math.clamp(t, 0, 1) * dy; + const on_edge = idx > 0 and @abs(pos.x - px) <= 0.05 and @abs(pos.y - py) <= 0.05; + var best: ?NodeId = null; + var best_distance: f32 = std.math.inf(f32); + for ([_]u32{ prev, idx }) |i| { + const nid = path.node_path[i]; + const node_entity = st.node_entities.get(nid) orelse continue; + if (!registeredIdentity(st, nid, node_entity)) continue; + const anchor = st.pf.graph.getPosition(nid); + if (anchor.y > pos.y + Y_FILTER_EPS) continue; + const node_distance = @sqrt((pos.x - anchor.x) * (pos.x - anchor.x) + (pos.y - anchor.y) * (pos.y - anchor.y)); + if (!on_edge and node_distance > MAX_NODE_SNAP_DISTANCE) continue; + if (node_distance < best_distance or (node_distance == best_distance and nid < (best orelse std.math.maxInt(NodeId)))) { + best = nid; + best_distance = node_distance; + } + } + if (best) |nid| { + if (publishEndpointAt(game, st, entity, nid, st.node_entities.get(nid).?, best_distance) catch false) + publishAttachedEndpoints(game, entity, @intCast(st.node_entities.get(nid).?)); + } + } + + // Carried items follow the walker's known node while within the normal + // placement radius. Only already tracked descendants participate; dropping + // or moving them independently still invalidates through the shared API. + fn publishAttachedEndpoints(game: anytype, parent: anytype, node_entity: @TypeOf(parent)) void { + if (comptime @hasDecl(@TypeOf(game.*), "getChildren")) { + for (game.getChildren(parent)) |child| { + if (game.positionRevision(child) != null) + _ = publishKnownEndpoint(game, child, node_entity) catch false; + publishAttachedEndpoints(game, child, node_entity); + } + } + } + + /// Lifecycle gate, separate from matching: false means retry preparation + /// before concluding that an active job/raid has no reachable candidates. + /// Off-graph is a completed result, while pending/stale is not. + pub fn endpointPreparationComplete(game: anytype, entity: anytype) bool { + const st = findState(game) orelse return false; + if (!preparedGraphCurrent(game, st)) return false; + const ecs = &game.active_world.ecs_backend; + if (!ecs.entityExists(entity)) return false; + if (ecs.hasComponent(entity, MovementNode)) return preparedEndpointNode(game, st, entity) != null; + const cmn = ecs.getComponent(entity, ClosestMovementNode) orelse return false; + return preparedStampCurrent(game, st, entity, cmn); + } + + fn preparedGraphCurrent(game: anytype, st: *State) bool { + const revision = st.graph_dependency_revision orelse return false; + return st.preparation_enabled and st.nodes_registered and + revision == game.dependencyRevision() and !st.pf.graph.dirty and st.pf.fw != null; + } + + fn registeredNode(game: anytype, st: *State, entity: anytype) ?NodeId { + const ecs = &game.active_world.ecs_backend; + if (!ecs.entityExists(entity)) return null; + const node = ecs.getComponent(entity, MovementNode) orelse return null; + if (st.node_entities.get(node.node_id) != @as(u64, @intCast(entity)) or st.pf.graph.isRemoved(node.node_id)) return null; + return node.node_id; + } + + fn preparedStampCurrent(game: anytype, st: *State, entity: anytype, cmn: *const ClosestMovementNode) bool { + const stamp = cmn.stamp; + return cmn.status != .pending and stamp.world_revision == st.preparation_world and + stamp.entity_id == @as(u64, @intCast(entity)) and stamp.graph_revision == st.graph_epoch and + stamp.position_revision != 0 and game.positionRevisionAt(entity, stamp.position_slot) == stamp.position_revision; + } + + fn registeredIdentity(st: *State, node: NodeId, entity: u64) bool { + const registered = if (node < st.registered_entities.items.len) + st.registered_entities.items[node] orelse st.node_entities.get(node) + else + st.node_entities.get(node); + return registered == entity and !st.pf.graph.isRemoved(node); + } + + fn preparedEndpointNode(game: anytype, st: *State, entity: anytype) ?NodeId { + const ecs = &game.active_world.ecs_backend; + if (!ecs.entityExists(entity)) return null; + if (ecs.getComponent(entity, MovementNode)) |node| { + return if (registeredIdentity(st, node.node_id, @intCast(entity))) node.node_id else null; + } + const cmn = ecs.getComponent(entity, ClosestMovementNode) orelse return null; + if (cmn.status != .ready or !preparedStampCurrent(game, st, entity, cmn)) return null; + // The graph dependency token rejects node destruction/component changes. + // Its immutable, generation-qualified registration needs no ECS reread. + if (!registeredIdentity(st, cmn.node_id, cmn.node_entity)) return null; + return cmn.node_id; + } + /// Graph path-length between two game entities, via each entity's /// nearest movement node. Returns `null` when the controller isn't /// set up, either entity has no nearby node (Y-filtered, see @@ -933,7 +1168,7 @@ pub const Controller = struct { const dx = node_pos.x - pos.x; const dy = node_pos.y - pos.y; const ne = st.node_entities.get(nid) orelse 0; - ecs.addComponent(target_entity, components.ClosestMovementNode{ + if (!st.preparation_enabled) ecs.addComponent(target_entity, components.ClosestMovementNode{ .node_entity = ne, .node_id = nid, .distance = @sqrt(dx * dx + dy * dy), @@ -1031,15 +1266,18 @@ pub const Controller = struct { return @sqrt(dx * dx + dy * dy); } - /// Re-resolve the entity's `ClosestMovementNode` immediately (v4: - /// replaces the game-side `PendingRepath` marker + next-tick - /// resolve round-trip — the plugin owns the CMN component, so - /// there's nothing to defer). Removes the CMN when the entity is - /// off-graph (no node within `MAX_NODE_SNAP_DISTANCE`). + /// In preparation mode, invalidate and queue the endpoint while retaining + /// its last published node for debugging. Legacy mode resolves immediately + /// and removes the component if no node is in range. pub fn requestRepath(game: anytype, entity: anytype) void { const st = findState(game) orelse return; const entity_id: u64 = @intCast(entity); const ecs = &game.active_world.ecs_backend; + if (st.preparation_enabled) { + if (ecs.getComponent(entity, ClosestMovementNode)) |cmn| cmn.invalidate(.position); + game.active_world.position_revisions.markPending(entity_id); + return; + } const pos = game.getWorldPosition(entity); if (findNearestNodeForEntity(st, entity_id, pos.x, pos.y)) |nid| { const node_pos = st.pf.graph.getPosition(nid); @@ -1128,7 +1366,67 @@ pub const Controller = struct { st.preparation_enabled = true; } - /// Serial snapshot + compute phase. Feed a union of game-owned domain views + /// Pending-only preparation for one fixed union of component types per + /// world/controller. Shared membership changes trigger discovery; ordinary + /// movement only enqueues already registered endpoints. Queries stay pure. + pub fn prepareEndpointDomains(game: anytype, comptime domains: anytype, budget: usize) !PreparationStats { + const st = findState(game) orelse return .{}; + if (st.preparation_in_flight) return error.PreparationInProgress; + const tracker = &game.active_world.position_revisions; + const ecs = &game.active_world.ecs_backend; + if (!st.domain_watches_ready) { + inline for (domains) |T| try tracker.watchMembership(game.allocator, T); + try tracker.watchMembership(game.allocator, MovementNode); + try tracker.watchMembership(game.allocator, ClosestMovementNode); + st.domain_watches_ready = true; + } + var discovered: usize = 0; + if (st.domain_revision == null or st.domain_revision.? != tracker.membership_revision) { + // Commit the revision only after every registration succeeds. OOM + // leaves the discovery pass retryable and publishes nothing. + inline for (domains) |T| { + var view = ecs.view(.{T}, .{}); + defer view.deinit(); + while (view.next()) |entity| { + discovered += 1; + if (!ecs.hasComponent(entity, MovementNode)) { + try tracker.watchChanges(game.allocator, @intCast(entity)); + const current = if (ecs.getComponent(entity, ClosestMovementNode)) |cmn| preparedStampCurrent(game, st, entity, cmn) else false; + if (!current) tracker.markPending(@intCast(entity)); + } + } + } + st.domain_revision = tracker.membership_revision; + } + if (st.domain_graph_epoch != st.graph_epoch) { + tracker.markAllPending(); + st.domain_graph_epoch = st.graph_epoch; + } + const Entity = @TypeOf(game.*).EntityType; + var participants = struct { + game: @TypeOf(game), + pending: @TypeOf(tracker.pendingIterator()), + pub fn next(self: *@This()) ?Entity { + while (self.pending.next()) |id| { + const entity: Entity = @intCast(id); + const backend = &self.game.active_world.ecs_backend; + if (backend.entityExists(entity) and !backend.hasComponent(entity, MovementNode)) { + inline for (domains) |T| { + if (backend.hasComponent(entity, T)) return entity; + } + } + self.game.active_world.position_revisions.unwatchChanges(id); + } + return null; + } + }{ .game = game, .pending = tracker.pendingIterator() }; + var result = try prepareEndpointBatch(game, &participants, budget); + result.domain_discovered = discovered; + st.preparation_stats.domain_discovered = discovered; + return result; + } + + /// Joined snapshot + compute phase. Feed a union of game-owned domain views /// once per frame after movement. The caller owns/deinitializes the iterator /// before calling publishEndpointBatch. No CMN is written here. pub fn prepareEndpointBatch(game: anytype, participants: anytype, budget: usize) !PreparationStats { @@ -1146,14 +1444,16 @@ pub const Controller = struct { while (participants.next()) |entity| { st.preparation_stats.discovered += 1; if (!ecs.entityExists(entity) or ecs.hasComponent(entity, MovementNode)) continue; - if (endpointStamp(game, st, entity)) |stamp| { - if (ecs.getComponent(entity, ClosestMovementNode)) |cmn| { - if (cmn.current(stamp)) continue; + if (ecs.getComponent(entity, ClosestMovementNode)) |cmn| { + if (preparedStampCurrent(game, st, entity, cmn)) { + game.active_world.position_revisions.acknowledge(@intCast(entity), cmn.stamp.position_revision); + continue; } } st.preparation_stats.pending += 1; st.preparation_batch.consider(@intCast(entity)); } + try ensureSearchNodes(st); const Entity = @TypeOf(game.*).EntityType; // Snapshot every selected entity before searching or publishing any. // OOM can register revision tokens, but cannot publish a partial batch. @@ -1168,15 +1468,21 @@ pub const Controller = struct { }); } for (st.preparation_snapshots.items) |*snapshot| { - st.preparation_stats.nearest_searches += 1; - if (findNearestNodeInState(st, snapshot.position.x, snapshot.position.y)) |node| { - const node_entity = st.node_entities.get(node) orelse continue; - const pos = st.pf.graph.getPosition(node); - const dx = pos.x - snapshot.position.x; - const dy = pos.y - snapshot.position.y; - snapshot.resolution = .{ .node_entity = node_entity, .node_id = node, .distance = @sqrt(dx * dx + dy * dy) }; + const entity: Entity = @intCast(snapshot.entity); + if (ecs.getComponent(entity, ClosestMovementNode)) |cmn| { + if (reusePreparedResolution(st, cmn, snapshot.stamp, snapshot.position)) |resolution| { + snapshot.resolution = resolution; + snapshot.search = false; + st.preparation_stats.reused_endpoints += 1; + continue; + } } + st.preparation_stats.nearest_searches += 1; } + var job = SearchJob{ .nodes = st.search_nodes.items, .endpoints = st.preparation_snapshots.items }; + if (st.preparation_worker) { + st.preparation_stats.worker_executed = game.active_world.snapshot_executor.run(&job, job.endpoints.len, SearchJob.run); + } else SearchJob.run(&job, 0, job.endpoints.len); st.preparation_stats.selected = st.preparation_batch.selected.items.len; st.preparation_stats.discarded = st.preparation_stats.selected - st.preparation_snapshots.items.len; st.preparation_in_flight = true; @@ -1217,6 +1523,7 @@ pub const Controller = struct { var cmn = if (ecs.getComponent(entity, ClosestMovementNode)) |old| old.* else ClosestMovementNode{}; if (cmn.publish(snapshot.stamp, stamp, snapshot.resolution)) { ecs.addComponent(entity, cmn); + game.active_world.position_revisions.acknowledge(snapshot.entity, stamp.position_revision); st.preparation_stats.published += 1; if (snapshot.resolution == null) st.preparation_stats.off_graph += 1; continue; @@ -1237,6 +1544,7 @@ pub const Controller = struct { // The complete ECS handle, including generation, is the identity. .entity_id = @intCast(entity), .position_revision = revision, + .position_slot = game.positionRevisionSlot(entity) orelse return null, .graph_revision = st.graph_epoch, }; } @@ -1245,6 +1553,7 @@ pub const Controller = struct { /// actual ECS inputs, including ancestor movement and authored edge edits. fn graphInputsCurrent(game: anytype, st: *State) bool { if (!st.nodes_registered or st.graph_epoch == 0) return false; + if (st.graph_dependency_revision == null or st.graph_dependency_revision.? != game.dependencyRevision()) return false; if (st.graph_inputs.count() != st.node_entities.count()) return false; const ecs = &game.active_world.ecs_backend; var view = ecs.view(.{MovementNode}, .{}); @@ -1347,10 +1656,26 @@ pub const Controller = struct { fn buildGraphIntoState(game: anytype, st: *State) void { const ecs = &game.active_world.ecs_backend; + st.graph_dependency_revision = null; + var watches_ok = st.preparation_enabled; + if (st.preparation_enabled) { + game.watchComponentDependency(MovementNode) catch { + watches_ok = false; + }; + game.watchComponentDependency(MovementStair) catch { + watches_ok = false; + }; + game.watchComponentDependency(MovementLink) catch { + watches_ok = false; + }; + } var view = ecs.view(.{MovementNode}, .{}); defer view.deinit(); while (view.next()) |entity| { + if (st.preparation_enabled) game.watchPositionDependency(entity) catch { + watches_ok = false; + }; // World position, not local: movement nodes declared // inside a room prefab (`Room.movement_nodes`) end up as // children of the room entity, so `getPosition` returns @@ -1379,6 +1704,14 @@ pub const Controller = struct { log.err("failed to track node {d}: {}", .{ node_id, err }); }; if (st.preparation_enabled) { + const old_len = st.registered_entities.items.len; + if (st.registered_entities.resize(st.allocator, @as(usize, node_id) + 1)) |_| { + @memset(st.registered_entities.items[old_len..], null); + st.registered_entities.items[node_id] = entity_id; + } else |_| { + // Optional fast index: the canonical registration map remains + // usable. Allocation failure must not invalidate graph watches. + } st.graph_inputs.put(st.allocator, node_id, .{ .position = pos, .stair = is_stair, @@ -1393,6 +1726,7 @@ pub const Controller = struct { linkAuthoredEdges(game, st); st.nodes_registered = st.node_entities.count() > 0; + if (watches_ok) st.graph_dependency_revision = game.dependencyRevision(); if (st.nodes_registered) { log.info("graph built: {d} nodes (Floyd-Warshall)", .{st.node_entities.count()}); } @@ -1501,7 +1835,12 @@ pub const Controller = struct { const pos = game.getPosition(entity); switch (directStep(pos, dm.target, dm.speed, dt)) { .arrived => settled.append(st.allocator, entity_id) catch break, - .move => |next| game.setPosition(entity, next), + .move => |next| { + game.setPosition(entity, next); + if (st.preparation_enabled) { + publishDirectEndpoint(game, st, entity) catch {}; + } + }, } } @@ -1784,6 +2123,88 @@ pub fn cachedNearestStillValid(cached: CachedNearest, x: f32, y: f32) bool { return dx * dx + dy * dy < CACHE_POS_THRESHOLD_SQ; } +/// Reuse is preparation work, never a distance-query fallback. The original +/// sample/radius are retained so small moves cannot accumulate unchecked drift. +fn reusePreparedResolution(st: *State, cmn: *const ClosestMovementNode, stamp: ClosestMovementNode.Stamp, pos: Position) ?ClosestMovementNode.Resolution { + if (cmn.status == .off_graph or !cmn.stamp.eql(cmn.last_ready_stamp) or cmn.stamp.world_revision != stamp.world_revision or + cmn.stamp.entity_id != stamp.entity_id or cmn.stamp.graph_revision != stamp.graph_revision or + cmn.invalidation == .graph or cmn.invalidation == .world) return null; + if (!Controller.registeredIdentity(st, cmn.node_id, cmn.node_entity)) return null; + const region = cmn.reuse; + if (!(region.radius > 0) or !std.math.isFinite(pos.x) or !std.math.isFinite(pos.y)) return null; + if (@as(f64, pos.y) < region.min_y or @as(f64, pos.y) >= region.max_y) return null; + const dx = @as(f64, pos.x) - region.x; + const dy = @as(f64, pos.y) - region.y; + if (dx * dx + dy * dy >= region.radius * region.radius) return null; + const node_pos = st.pf.graph.getPosition(cmn.node_id); + const nx = node_pos.x - pos.x; + const ny = node_pos.y - pos.y; + const distance = @sqrt(nx * nx + ny * ny); + if (!std.math.isFinite(distance) or distance > MAX_NODE_SNAP_DISTANCE or node_pos.y > pos.y + Y_FILTER_EPS) return null; + return .{ .node_entity = cmn.node_entity, .node_id = cmn.node_id, .distance = distance, .reuse = region }; +} + +const SearchJob = struct { + nodes: []const SearchNode, + endpoints: []EndpointSnapshot, + fn run(raw: *anyopaque, start: usize, end: usize) void { + const job: *@This() = @ptrCast(@alignCast(raw)); + for (job.endpoints[start..end]) |*endpoint| { + if (endpoint.search) endpoint.resolution = resolvePreparedNearest(job.nodes, endpoint.position); + } + } +}; + +/// Same selection/tie order as findNearestNodeInState, with a conservative region +/// where the winner cannot change. Distance differences are 2-Lipschitz: moving +/// less than one quarter of the runner-up gap stays strictly inside that region. +/// Separate Y bounds preserve the eligible node set during horizontal movement. +fn resolvePreparedNearest(nodes: []const SearchNode, pos: Position) ?ClosestMovementNode.Resolution { + if (!std.math.isFinite(pos.x) or !std.math.isFinite(pos.y)) return null; + var best: ?SearchNode = null; + var best_distance: f32 = std.math.inf(f32); + var best_exact: f64 = std.math.inf(f64); + var second_exact: f64 = std.math.inf(f64); + var min_y: f64 = -std.math.inf(f64); + var max_y: f64 = std.math.inf(f64); + for (nodes) |node| { + const point = node.position; + const boundary = @as(f64, point.y) - Y_FILTER_EPS; + const padding = 0.00001 * @max(1, @max(@abs(@as(f64, point.y)), @abs(@as(f64, pos.y)))); + if (point.y > pos.y + Y_FILTER_EPS) { + max_y = @min(max_y, boundary - padding); + continue; + } + min_y = @max(min_y, boundary + padding); + const dx = point.x - pos.x; + const dy = point.y - pos.y; + const distance = @sqrt(dx * dx + dy * dy); + const ex = @as(f64, point.x) - pos.x; + const ey = @as(f64, point.y) - pos.y; + const exact = @sqrt(ex * ex + ey * ey); + if (distance < best_distance) { + second_exact = @min(second_exact, best_exact); + best_distance = distance; + best_exact = exact; + best = node; + } else { + second_exact = @min(second_exact, exact); + } + } + const node = best orelse return null; + if (best_distance > MAX_NODE_SNAP_DISTANCE) return null; + + const gap = if (std.math.isFinite(second_exact)) second_exact - best_exact else MAX_NODE_SNAP_DISTANCE; + // Leave generous room for the f32 arithmetic used by the reference selector. + const rounding_guard = 0.0001 * @max(1, best_exact); + return .{ + .node_entity = node.entity, + .node_id = node.id, + .distance = best_distance, + .reuse = .{ .x = pos.x, .y = pos.y, .radius = @max(0, gap * 0.25 - rounding_guard), .min_y = min_y, .max_y = max_y }, + }; +} + pub fn findNearestNodeInState(st: *State, x: f32, y: f32) ?NodeId { const slots = st.pf.graph.totalSlots(); if (slots == 0) return null; @@ -1845,6 +2266,17 @@ fn settleArrival(game: anytype, st: *State, entity_id: u64, goal_node: ?NodeId) if (!ecs.entityExists(entity)) return; ecs.removeComponent(entity, Navigating); + if (st.preparation_enabled) { + var arrival_node: u64 = 0; + if (goal_node) |node| { + if (st.node_entities.get(node)) |node_entity| { + if (Controller.publishKnownEndpoint(game, entity, @as(Entity, @intCast(node_entity))) catch false) arrival_node = node_entity; + } + } + if (arrival_node == 0) Controller.requestRepath(game, entity); + emitGameEvent(game, "pathfinder__arrived", .{ .entity = entity_id, .node_entity = arrival_node }); + return; + } // CMN refresh replaces the old game-side arrival → NeedsClosestNode // marker → next-tick resolve round-trip: the plugin owns the CMN @@ -1931,6 +2363,11 @@ fn GameCtx(comptime GameType: type) type { game: GameType, st: *State, + pub fn onPathStep(self: *@This(), entity_id: u64, path: *const movement_path.MovementPath) void { + const Entity = @TypeOf(self.game.*).EntityType; + Controller.publishWalkingEndpoint(self.game, self.st, @as(Entity, @intCast(entity_id)), path); + } + pub fn onArrived(self: *@This(), entity_id: u64, goal_node: NodeId) void { settleArrival(self.game, self.st, entity_id, goal_node); } diff --git a/src/nav/engine.zig b/src/nav/engine.zig index edc0311..b6e6b26 100644 --- a/src/nav/engine.zig +++ b/src/nav/engine.zig @@ -240,6 +240,10 @@ pub fn PathfinderWith( break; } + // Publish the walker's route endpoint after all movement for + // this entity, before arrival callbacks or gameplay matching. + if (comptime @hasDecl(@TypeOf(ctx.*), "onPathStep")) ctx.onPathStep(entity, path); + // Check if arrived at final destination if (path.current_index >= path.len) { arrived_list.append(self.allocator, entity) catch |err| { diff --git a/tests/integration/preparation_test.zig b/tests/integration/preparation_test.zig index d2eaf3b..806bf45 100644 --- a/tests/integration/preparation_test.zig +++ b/tests/integration/preparation_test.zig @@ -280,3 +280,569 @@ test "legacy targetPosition never treats off-graph debugging data as a ready nod try testing.expectEqual(old.node_entity, off_graph.node_entity); try testing.expectEqualDeep(game.getWorldPosition(e), C.targetPosition(&game, e)); } + +test "prepared matching rejects stale endpoints and graph inputs without repairing them" { + var game = Game.init(testing.allocator); + defer game.deinit(); + const n = node(&game, 0); + const n2 = node(&game, 50); + const a = item(&game, 1); + const b = item(&game, 51); + try setup(&game); + defer C.deinit(&game); + try testing.expectEqual(null, C.preparedDistance(&game, a, b)); + _ = try run(&game, 8); + try testing.expectEqual(@as(?f32, 50), C.preparedDistance(&game, a, b)); + try testing.expectEqual(@as(?f32, 50), C.preparedDistance(&game, n, n2)); + C.requestRepath(&game, a); + try testing.expectEqual(CMN.Status.pending, game.getComponent(a, CMN).?.status); + try testing.expectEqual(null, C.preparedDistance(&game, a, b)); + _ = try run(&game, 8); + try testing.expectEqual(@as(?f32, 50), C.preparedDistance(&game, a, b)); + try testing.expect(!game.hasComponent(n, CMN)); + const old = game.getComponent(a, CMN).?.*; + game.setPosition(a, .{ .x = 2 }); + try testing.expectEqual(null, C.preparedDistance(&game, a, b)); + try testing.expect(!C.endpointPreparationComplete(&game, a)); + try testing.expectEqualDeep(old, game.getComponent(a, CMN).?.*); + _ = try run(&game, 8); + try testing.expect(C.endpointPreparationComplete(&game, a)); + game.setPosition(n2, .{ .x = 60 }); + // Even a query involving only the unchanged node must reject the graph. + try testing.expectEqual(null, C.preparedDistance(&game, n, n)); + C.advance(&game, 0); + _ = try run(&game, 8); + try testing.expectEqual(@as(?f32, 60), C.preparedDistance(&game, a, b)); + game.addComponent(n, pf.MovementStair{}); + try testing.expectEqual(null, C.preparedDistance(&game, a, b)); + C.advance(&game, 0); + _ = try run(&game, 8); + _ = node(&game, 75); + try testing.expectEqual(null, C.preparedDistance(&game, a, b)); +} + +test "off-graph is complete but never a match; missing controller never falls back" { + var game = Game.init(testing.allocator); + defer game.deinit(); + const a = item(&game, 0); + const b = item(&game, 10000); + try testing.expectEqual(null, C.preparedDistance(&game, a, a)); + _ = node(&game, 0); + try setup(&game); + defer C.deinit(&game); + _ = try run(&game, 8); + try testing.expect(C.endpointPreparationComplete(&game, b)); + try testing.expectEqual(null, C.preparedDistance(&game, a, b)); + try testing.expectEqual(null, C.preparedDistance(&game, b, b)); +} + +test "known docking placement validates world geometry and is invalidated by ancestry" { + var game = Game.init(testing.allocator); + defer game.deinit(); + const dock = node(&game, 0); + const other = node(&game, 500); + const parent = game.createEntity(); + const bandit = item(&game, 1); + game.setParent(bandit, parent, .{}); + try testing.expect(!try C.publishKnownEndpoint(&game, bandit, dock)); + try setup(&game); + defer C.deinit(&game); + _ = try run(&game, 8); + try testing.expect(try C.publishKnownEndpoint(&game, bandit, dock)); + try testing.expectEqual(@as(?f32, 0), C.preparedDistance(&game, bandit, dock)); + try testing.expectEqual(null, C.preparedDistance(&game, bandit, other)); + try testing.expect(!try C.publishKnownEndpoint(&game, bandit, other)); + try testing.expect(!try C.publishKnownEndpoint(&game, dock, dock)); + game.setPosition(parent, .{ .x = 5 }); + try testing.expectEqual(null, C.preparedDistance(&game, bandit, dock)); + try testing.expect(try C.publishKnownEndpoint(&game, bandit, dock)); + game.setPosition(parent, .{ .y = -10 }); // anchor is above the allowed Y filter + try testing.expect(!try C.publishKnownEndpoint(&game, bandit, dock)); + try testing.expectEqual(null, C.preparedDistance(&game, bandit, dock)); + game.setPosition(parent, .{ .x = 1000 }); + try testing.expect(!try C.publishKnownEndpoint(&game, bandit, dock)); +} + +test "small moves reuse only during preparation and preserve the original region" { + var game = Game.init(testing.allocator); + defer game.deinit(); + const n = node(&game, 0); + _ = node(&game, 50); + const e = item(&game, 1); + try setup(&game); + defer C.deinit(&game); + _ = try run(&game, 1); + const region = game.getComponent(e, CMN).?.reuse; + for (2..10) |x| { + game.setPosition(e, .{ .x = @floatFromInt(x) }); + try testing.expectEqual(null, C.preparedDistance(&game, e, n)); + const stats = try stage(&game, 1); + try testing.expectEqual(@as(usize, 1), stats.reused_endpoints); + try testing.expectEqual(@as(usize, 0), stats.nearest_searches); + try testing.expectEqual(null, C.preparedDistance(&game, e, n)); + _ = C.publishEndpointBatch(&game); + try testing.expectEqual(@as(?f32, 0), C.preparedDistance(&game, e, n)); + try testing.expectEqualDeep(region, game.getComponent(e, CMN).?.reuse); + } + game.setPosition(e, .{ .x = 30 }); + try testing.expectEqual(@as(usize, 1), (try stage(&game, 1)).nearest_searches); + _ = C.publishEndpointBatch(&game); + try testing.expectEqual(@as(?f32, 50), C.preparedDistance(&game, e, n)); +} + +test "reuse agrees with exhaustive nearest selection across ties Y boundaries and teleports" { + var game = Game.init(testing.allocator); + defer game.deinit(); + _ = node(&game, 0); + _ = node(&game, 50); + const upper = node(&game, 25); + game.setPosition(upper, .{ .x = 25, .y = 10 }); + _ = node(&game, 50); // duplicate: retain first-node tie order + const e = item(&game, 1); + try setup(&game); + defer C.deinit(&game); + var reused: usize = 0; + for (0..600) |i| { + const x: f32 = @as(f32, @floatFromInt(i % 151)) - 40; + const y: f32 = switch ((i / 151) % 4) { + 0 => 0, + 1 => 4.99999, + 2 => 5, + else => 5.00001, + }; + game.setPosition(e, .{ .x = x, .y = y }); + const stats = try run(&game, 1); + reused += stats.reused_endpoints; + const expected = pf.controller.findNearestNodeInState(C.getState(&game).?, x, y); + const cmn = game.getComponent(e, CMN).?; + if (expected) |id| { + try testing.expectEqual(CMN.Status.ready, cmn.status); + try testing.expectEqual(id, cmn.node_id); + } else try testing.expectEqual(CMN.Status.off_graph, cmn.status); + } + try testing.expect(reused > 0); + game.setPosition(e, .{ .x = 10000 }); + try testing.expectEqual(@as(usize, 1), (try run(&game, 1)).off_graph); + game.setPosition(e, .{ .x = 1 }); + try testing.expectEqual(@as(usize, 1), (try run(&game, 1)).nearest_searches); +} + +test "known placements and changed graphs cannot reuse a nearest certificate" { + var game = Game.init(testing.allocator); + defer game.deinit(); + const n = node(&game, 0); + const e = item(&game, 1); + try setup(&game); + defer C.deinit(&game); + _ = try run(&game, 1); + try testing.expect(try C.publishKnownEndpoint(&game, e, n)); + game.setPosition(e, .{ .x = 2 }); + try testing.expectEqual(@as(usize, 1), (try run(&game, 1)).nearest_searches); + game.setPosition(n, .{ .x = 3 }); + C.advance(&game, 0); + try testing.expectEqual(@as(usize, 1), (try run(&game, 1)).nearest_searches); + game.setPosition(e, .{ .x = 4 }); + try testing.expectEqual(@as(usize, 1), (try stage(&game, 1)).reused_endpoints); + game.setPosition(e, .{ .x = 5 }); + try testing.expectEqual(@as(usize, 1), C.publishEndpointBatch(&game).discarded); + try testing.expectEqual(null, C.preparedDistance(&game, e, n)); +} + +fn domainRun(game: *Game, budget: usize) !pf.controller.PreparationStats { + _ = try C.prepareEndpointDomains(game, .{Participant}, budget); + return C.publishEndpointBatch(game); +} + +test "pending-only domains drain and idle frames visit no participants" { + var game = Game.init(testing.allocator); + defer game.deinit(); + const n = node(&game, 0); + const parent = game.createEntity(); + const a = item(&game, 1); + const b = item(&game, 2); + game.setParent(a, parent, .{}); + try setup(&game); + defer C.deinit(&game); + const first = try domainRun(&game, 1); + try testing.expectEqual(@as(usize, 2), first.domain_discovered); + try testing.expectEqual(@as(usize, 2), first.discovered); + try testing.expectEqual(@as(usize, 1), (try domainRun(&game, 1)).discovered); + for (0..5) |_| { + const idle = try domainRun(&game, 1); + try testing.expectEqual(@as(usize, 0), idle.domain_discovered); + try testing.expectEqual(@as(usize, 0), idle.discovered); + try testing.expectEqual(@as(usize, 0), idle.nearest_searches); + } + game.setPosition(parent, .{ .x = 3 }); + try testing.expectEqual(null, C.preparedDistance(&game, a, n)); + const moved = try C.prepareEndpointDomains(&game, .{Participant}, 1); + try testing.expectEqual(@as(usize, 0), moved.domain_discovered); + try testing.expectEqual(@as(usize, 1), moved.discovered); + try testing.expectEqual(null, C.preparedDistance(&game, a, n)); + _ = C.publishEndpointBatch(&game); + try testing.expectEqual(@as(?f32, 0), C.preparedDistance(&game, a, b)); + game.setComponent(a, Participant{ .value = 3 }); + try testing.expectEqual(@as(usize, 0), (try domainRun(&game, 1)).domain_discovered); +} + +test "pending domains discover additions and component removal without count heuristics" { + var game = Game.init(testing.allocator); + defer game.deinit(); + _ = node(&game, 0); + const a = item(&game, 1); + const b = game.createEntity(); + game.setPosition(b, .{ .x = 2 }); + try setup(&game); + defer C.deinit(&game); + _ = try domainRun(&game, 4); + game.removeComponent(a, Participant); + game.addComponent(b, Participant{}); // same participant count, different identity + game.setPosition(a, .{ .x = 4 }); + const changed = try domainRun(&game, 4); + try testing.expectEqual(@as(usize, 1), changed.domain_discovered); + try testing.expectEqual(@as(usize, 1), changed.published); + try testing.expect(C.endpointPreparationComplete(&game, b)); + game.setPosition(a, .{ .x = 5 }); + try testing.expectEqual(@as(usize, 0), (try domainRun(&game, 4)).discovered); + game.active_world.ecs_backend.addComponent(a, Participant{}); + game.invalidateComponentMembership(Participant); + try testing.expectEqual(@as(usize, 1), (try domainRun(&game, 4)).published); + game.destroyEntity(b); + try testing.expectEqual(@as(usize, 0), (try domainRun(&game, 4)).discovered); +} + +test "pending work survives cancellation stale publication off graph and repath" { + var game = Game.init(testing.allocator); + defer game.deinit(); + _ = node(&game, 0); + const a = item(&game, 1); + const b = item(&game, 10000); + try setup(&game); + defer C.deinit(&game); + _ = try C.prepareEndpointDomains(&game, .{Participant}, 1); + C.cancelEndpointBatch(&game); + try testing.expectEqual(@as(usize, 1), (try domainRun(&game, 1)).published); + _ = try C.prepareEndpointDomains(&game, .{Participant}, 1); + game.setPosition(b, .{ .x = 20000 }); + try testing.expectEqual(@as(usize, 1), C.publishEndpointBatch(&game).discarded); + try testing.expectEqual(@as(usize, 1), (try domainRun(&game, 1)).off_graph); + try testing.expectEqual(@as(usize, 0), (try domainRun(&game, 1)).discovered); + C.requestRepath(&game, a); + try testing.expectEqual(@as(usize, 1), (try domainRun(&game, 1)).published); + game.setPosition(b, .{ .x = 2 }); + try testing.expectEqual(@as(usize, 1), (try domainRun(&game, 1)).published); +} + +test "graph changes and node role changes requeue endpoints; moving entities cannot starve others" { + var game = Game.init(testing.allocator); + defer game.deinit(); + const n = node(&game, 0); + const a = item(&game, 1); + const b = item(&game, 2); + const c = item(&game, 3); + try setup(&game); + defer C.deinit(&game); + _ = try domainRun(&game, 1); + game.setPosition(a, .{ .x = 4 }); + _ = try domainRun(&game, 1); + try testing.expect(C.endpointPreparationComplete(&game, b)); + game.setPosition(a, .{ .x = 5 }); + _ = try domainRun(&game, 1); + try testing.expect(C.endpointPreparationComplete(&game, c)); + _ = try domainRun(&game, 4); + game.setPosition(n, .{ .x = 10 }); + C.advance(&game, 0); + const rebuilt = try domainRun(&game, 4); + try testing.expectEqual(@as(usize, 0), rebuilt.domain_discovered); + try testing.expectEqual(@as(usize, 3), rebuilt.published); + game.addComponent(a, pf.MovementNode{}); + C.advance(&game, 0); + _ = try domainRun(&game, 4); + game.removeComponent(a, pf.MovementNode); + C.advance(&game, 0); + _ = try domainRun(&game, 4); + try testing.expect(C.endpointPreparationComplete(&game, a)); +} + +test "pending discovery retries after allocation failure and controller replacement" { + var game = Game.init(testing.allocator); + defer game.deinit(); + _ = node(&game, 0); + const e = item(&game, 1); + try setup(&game); + defer C.deinit(&game); + const state = C.getState(&game).?; + var bytes: [0]u8 = .{}; + var allocator = std.heap.FixedBufferAllocator.init(&bytes); + { + state.allocator = allocator.allocator(); + defer state.allocator = testing.allocator; + try testing.expectError(error.OutOfMemory, C.prepareEndpointDomains(&game, .{Participant}, 1)); + } + try testing.expect(!C.endpointPreparationComplete(&game, e)); + try testing.expectEqual(@as(usize, 1), (try domainRun(&game, 1)).published); + const old_world = game.getComponent(e, CMN).?.stamp.world_revision; + try C.setup(&game); + C.enableEndpointPreparation(&game); + C.advance(&game, 0); + try testing.expectEqual(@as(usize, 1), (try domainRun(&game, 1)).published); + try testing.expect(game.getComponent(e, CMN).?.stamp.world_revision != old_world); + game.removeComponent(e, CMN); + try testing.expectEqual(@as(usize, 1), (try domainRun(&game, 1)).published); +} + +test "pending and full-view preparation publish identical frame outcomes" { + var reference = Game.init(testing.allocator); + defer reference.deinit(); + var queued = Game.init(testing.allocator); + defer queued.deinit(); + const rn = node(&reference, 0); + const qn = node(&queued, 0); + _ = node(&reference, 50); + _ = node(&queued, 50); + var re: [6]Entity = undefined; + var qe: [6]Entity = undefined; + for (0..5) |i| { + re[i] = item(&reference, @floatFromInt(i * 10)); + qe[i] = item(&queued, @floatFromInt(i * 10)); + } + try setup(&reference); + defer C.deinit(&reference); + try setup(&queued); + defer C.deinit(&queued); + C.setPreparationWorker(&queued, true); + var count: usize = 5; + for (0..60) |frame| { + if (frame == 10) { + re[5] = item(&reference, 10000); + qe[5] = item(&queued, 10000); + count = 6; + } + if (frame == 20) { + reference.removeComponent(re[2], Participant); + queued.removeComponent(qe[2], Participant); + } + if (frame == 30) { + reference.addComponent(re[2], Participant{}); + queued.addComponent(qe[2], Participant{}); + } + if (frame == 40) { + reference.setPosition(rn, .{ .x = 5 }); + queued.setPosition(qn, .{ .x = 5 }); + C.advance(&reference, 0); + C.advance(&queued, 0); + } + const i = frame % count; + const pos = engine.Position{ .x = @floatFromInt(frame % 70) }; + reference.setPosition(re[i], pos); + queued.setPosition(qe[i], pos); + const full = try run(&reference, 2); + const pending = try domainRun(&queued, 2); + try testing.expectEqual(full.selected, pending.selected); + try testing.expectEqual(full.published, pending.published); + try testing.expectEqual(full.off_graph, pending.off_graph); + for (0..count) |j| { + try testing.expectEqual(C.preparedDistance(&reference, re[j], rn), C.preparedDistance(&queued, qe[j], qn)); + } + } +} + +test "prepared origin scans reject movement repath destruction and controller replacement" { + var game = Game.init(testing.allocator); + defer game.deinit(); + const n = node(&game, 0); + const n2 = node(&game, 50); + const a = item(&game, 1); + const b = item(&game, 51); + try setup(&game); + defer C.deinit(&game); + try testing.expectEqual(null, C.preparedDistancesFrom(&game, a)); + _ = try domainRun(&game, 8); + var scan = C.preparedDistancesFrom(&game, a).?; + try testing.expectEqual(C.preparedDistance(&game, a, b), scan.to(b)); + // A validated known placement can change the node without changing the stamp. + try testing.expect(try C.publishKnownEndpoint(&game, a, n2)); + try testing.expectEqual(null, scan.to(b)); + scan = C.preparedDistancesFrom(&game, a).?; + C.requestRepath(&game, a); + try testing.expectEqual(null, scan.to(b)); + _ = try domainRun(&game, 8); + scan = C.preparedDistancesFrom(&game, a).?; + game.setPosition(a, .{ .x = 2 }); + try testing.expectEqual(null, scan.to(b)); + _ = try domainRun(&game, 8); + scan = C.preparedDistancesFrom(&game, a).?; + var direct = C.preparedDistancesFrom(&game, n).?; + game.setPosition(n2, .{ .x = 60 }); + try testing.expectEqual(null, scan.to(b)); + try testing.expectEqual(null, direct.to(n)); + C.advance(&game, 0); + _ = try domainRun(&game, 8); + scan = C.preparedDistancesFrom(&game, a).?; + try C.setup(&game); + C.enableEndpointPreparation(&game); + C.advance(&game, 0); + _ = try domainRun(&game, 8); + try testing.expectEqual(null, scan.to(b)); + scan = C.preparedDistancesFrom(&game, a).?; + game.destroyEntity(a); + try testing.expectEqual(null, scan.to(b)); +} + +test "worker batch joins all results and retains publication cancellation and stale checks" { + var game = Game.init(testing.allocator); + defer game.deinit(); + const n = node(&game, 0); + const a = item(&game, 1); + const b = item(&game, 2); + const c = item(&game, 10000); + try setup(&game); + defer C.deinit(&game); + C.setPreparationWorker(&game, true); + const computed = try C.prepareEndpointDomains(&game, .{Participant}, 3); + if (!@import("builtin").single_threaded) try testing.expect(computed.worker_executed); + try testing.expectEqual(null, C.preparedDistance(&game, a, n)); + C.cancelEndpointBatch(&game); + try testing.expect(!game.hasComponent(a, CMN)); + _ = try C.prepareEndpointDomains(&game, .{Participant}, 3); + game.setPosition(b, .{ .x = 3 }); + const published = C.publishEndpointBatch(&game); + try testing.expectEqual(@as(usize, 1), published.discarded); + try testing.expectEqual(@as(usize, 1), published.off_graph); + try testing.expectEqual(@as(?f32, 0), C.preparedDistance(&game, a, n)); + try testing.expectEqual(null, C.preparedDistance(&game, b, n)); + try testing.expect(C.endpointPreparationComplete(&game, c)); + _ = try domainRun(&game, 3); + try testing.expectEqual(@as(?f32, 0), C.preparedDistance(&game, b, n)); +} + +test "legacy navigation target lookup preserves stale matching debug records" { + var game = Game.init(testing.allocator); + defer game.deinit(); + _ = node(&game, 0); + _ = node(&game, 50); + const a = item(&game, 1); + try setup(&game); + defer C.deinit(&game); + _ = try domainRun(&game, 1); + game.setPosition(a, .{ .x = 51 }); + const old = game.getComponent(a, CMN).?.*; + _ = C.targetPosition(&game, a); + try testing.expectEqualDeep(old, game.getComponent(a, CMN).?.*); + try testing.expect(!C.endpointPreparationComplete(&game, a)); + _ = try domainRun(&game, 1); + try testing.expect(C.endpointPreparationComplete(&game, a)); +} + +test "walkers publish each frame without spending the preparation search budget" { + var game = Game.init(testing.allocator); + defer game.deinit(); + _ = node(&game, 0); + _ = node(&game, 70); + const goal = node(&game, 140); + const mover = item(&game, 0); + const carried = item(&game, 0); + game.setParent(carried, mover, .{}); + try setup(&game); + defer C.deinit(&game); + _ = try domainRun(&game, 8); + _ = C.navigate(&game, mover, .{ .x = 140, .y = 0 }, @src()); + for (0..20) |_| { + C.advance(&game, 0.125); // low frame rate also needs no nearest searches + try testing.expect(C.preparedDistance(&game, mover, goal) != null); + try testing.expect(C.preparedDistance(&game, carried, goal) != null); + const stats = try domainRun(&game, 8); + try testing.expectEqual(@as(usize, 0), stats.nearest_searches); + } + // An external move immediately invalidates; cancellation does not bless it. + game.setPosition(mover, .{ .x = 10000 }); + C.cancel(&game, mover); + try testing.expectEqual(null, C.preparedDistance(&game, mover, goal)); + try testing.expectEqual(null, C.preparedDistance(&game, carried, goal)); + try testing.expect((try domainRun(&game, 8)).nearest_searches > 0); +} + +test "walker route endpoints remain current on long authored edges and reject graph changes" { + var game = Game.init(testing.allocator); + defer game.deinit(); + const a = node(&game, 0); + const b = node(&game, 600); + game.addComponent(a, pf.MovementLink{ .dx = 600 }); + const mover = item(&game, 0); + game.addComponent(mover, pf.MovementSpeed{ .speed = 100 }); + try setup(&game); + defer C.deinit(&game); + _ = try domainRun(&game, 8); + _ = C.navigate(&game, mover, .{ .x = 600, .y = 0 }, @src()); + for (0..4) |_| { + C.advance(&game, 1); + try testing.expect(C.preparedDistance(&game, mover, b) != null); + try testing.expectEqual(@as(usize, 0), (try domainRun(&game, 8)).nearest_searches); + } + game.setPosition(b, .{ .x = 610 }); + try testing.expectEqual(null, C.preparedDistance(&game, mover, a)); +} + +test "direct walks retain a validated known anchor and dropping invalidates attached items" { + var game = Game.init(testing.allocator); + defer game.deinit(); + const n = node(&game, 0); + const mover = item(&game, 1); + const carried = item(&game, 0); + game.setParent(carried, mover, .{}); + try setup(&game); + defer C.deinit(&game); + _ = try domainRun(&game, 8); + _ = C.moveTo(&game, mover, .{ .x = 60, .y = 0 }); + C.advance(&game, 0.125); + try testing.expect(C.preparedDistance(&game, mover, n) != null); + try testing.expect(C.preparedDistance(&game, carried, n) != null); + try testing.expectEqual(@as(usize, 0), (try domainRun(&game, 8)).nearest_searches); + game.removeParentKeepTransform(carried); + try testing.expectEqual(null, C.preparedDistance(&game, carried, n)); + _ = try domainRun(&game, 8); + try testing.expect(C.preparedDistance(&game, carried, n) != null); +} + +test "direct movers switch to nearer nodes before leaving the old snap radius" { + var game = Game.init(testing.allocator); + defer game.deinit(); + _ = node(&game, 0); + const nearer = node(&game, 40); + _ = node(&game, 80); + const mover = item(&game, 1); + const carried = item(&game, 0); + game.setParent(carried, mover, .{}); + try setup(&game); + defer C.deinit(&game); + _ = try domainRun(&game, 8); + _ = C.moveTo(&game, mover, .{ .x = 100, .y = 0 }); + // Default direct speed is 200 px/s: cross the first nearest-node boundary. + C.advance(&game, 0.2); + const pos = game.getWorldPosition(mover); + try testing.expect(pos.x > 20 and pos.x < 60); + try testing.expectEqual(@as(u64, @intCast(nearer)), game.getComponent(mover, CMN).?.node_entity); + try testing.expectEqual(@as(u64, @intCast(nearer)), game.getComponent(carried, CMN).?.node_entity); + try testing.expect(C.endpointPreparationComplete(&game, mover)); +} + +test "missing optional registration index preserves prepared queries and graph epoch" { + var game = Game.init(testing.allocator); + defer game.deinit(); + const n = node(&game, 0); + const mover = item(&game, 1); + try setup(&game); + defer C.deinit(&game); + _ = try domainRun(&game, 8); + var view = game.active_world.ecs_backend.view(.{pf.ControllerState}, .{}); + defer view.deinit(); + const holder = view.next().?; + const state: *pf.controller.State = @ptrFromInt(game.getComponent(holder, pf.ControllerState).?.state_ptr); + const epoch = state.graph_epoch; + // Same absent-index state as an allocation failure during registration. + state.registered_entities.clearRetainingCapacity(); + try testing.expect(C.preparedDistance(&game, mover, n) != null); + C.advance(&game, 0); + try testing.expectEqual(epoch, state.graph_epoch); + try testing.expect(C.preparedDistance(&game, mover, n) != null); +} diff --git a/tests/nav/prepared_endpoint_test.zig b/tests/nav/prepared_endpoint_test.zig index c2e8a75..1795e55 100644 --- a/tests/nav/prepared_endpoint_test.zig +++ b/tests/nav/prepared_endpoint_test.zig @@ -3,7 +3,7 @@ const testing = std.testing; const pathfinder = @import("pathfinder"); const CMN = pathfinder.ClosestMovementNode; -const initial = CMN.Stamp{ .world_revision = 1, .entity_id = 42, .entity_generation = 0, .position_revision = 1, .graph_revision = 1 }; +const initial = CMN.Stamp{ .world_revision = 1, .entity_id = 42, .entity_generation = 0, .position_revision = 1, .position_slot = 0, .graph_revision = 1 }; const node_zero = CMN.Resolution{ .node_entity = 7, .node_id = 0, .distance = 0 }; test "CMN defaults and legacy writes stay pending; node zero can be published" {