From addab4016eabfff98d62c383b1ddecdf6c4f3373 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexandre=20Calv=C3=A3o?= Date: Thu, 15 Oct 2026 13:51:43 +0000 Subject: [PATCH 1/9] feat(pathfinder): query current prepared endpoints without fallback --- .github/workflows/ci.yml | 2 +- rfc/prepared-endpoint-implementation.md | 29 ++++++++ src/nav/controller.zig | 94 +++++++++++++++++++++++++ tests/integration/preparation_test.zig | 77 ++++++++++++++++++++ 4 files changed, 201 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 76a3586..451047a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -33,7 +33,7 @@ jobs: 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 58c060d227f614b8476836b6d74b163a9be4f53d git -C ../labelle-engine checkout --detach FETCH_HEAD - name: Run engine integration tests 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/controller.zig b/src/nav/controller.zig index 486a630..69f81f6 100644 --- a/src/nav/controller.zig +++ b/src/nav/controller.zig @@ -223,6 +223,7 @@ pub const State = struct { pf: Pf, preparation_world: u64, preparation_enabled: bool = false, + graph_dependency_revision: ?u64 = null, preparation_in_flight: bool = false, preparation_stats: PreparationStats = .{}, preparation_batch: @import("preparation_batch.zig").PreparationBatch = .{}, @@ -770,6 +771,77 @@ 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); + } + + /// 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; + _ = 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 = @intCast(node_entity), .node_id = node, .distance = distance_to_node })) return false; + ecs.addComponent(entity, cmn); + return true; + } + + /// 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 cmn.current(endpointStamp(game, st, entity) orelse return false); + } + + 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 preparedEndpointNode(game: anytype, st: *State, entity: anytype) ?NodeId { + const ecs = &game.active_world.ecs_backend; + if (!ecs.entityExists(entity)) return null; + if (ecs.hasComponent(entity, MovementNode)) return registeredNode(game, st, entity); + const cmn = ecs.getComponent(entity, ClosestMovementNode) orelse return null; + if (!cmn.ready(endpointStamp(game, st, entity) orelse return null)) return null; + const node = registeredNode(game, st, @as(@TypeOf(entity), @intCast(cmn.node_entity))) orelse return null; + if (node != cmn.node_id) return null; + return node; + } + /// 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 @@ -1037,6 +1109,10 @@ pub const Controller = struct { 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); + return; + } const pos = game.getWorldPosition(entity); if (findNearestNodeForEntity(st, entity_id, pos.x, pos.y)) |nid| { const node_pos = st.pf.graph.getPosition(nid); @@ -1242,6 +1318,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}, .{}); @@ -1344,10 +1421,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 @@ -1390,6 +1483,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()}); } diff --git a/tests/integration/preparation_test.zig b/tests/integration/preparation_test.zig index d2eaf3b..a6be232 100644 --- a/tests/integration/preparation_test.zig +++ b/tests/integration/preparation_test.zig @@ -280,3 +280,80 @@ 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)); + 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)); +} From c4be09a50a062097f45eb284405e6c26d762080c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexandre=20Calv=C3=A3o?= Date: Thu, 15 Oct 2026 13:57:57 +0000 Subject: [PATCH 2/9] test(pathfinder): cover deferred explicit repath --- tests/integration/preparation_test.zig | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/tests/integration/preparation_test.zig b/tests/integration/preparation_test.zig index a6be232..823ed82 100644 --- a/tests/integration/preparation_test.zig +++ b/tests/integration/preparation_test.zig @@ -294,6 +294,11 @@ test "prepared matching rejects stale endpoints and graph inputs without repairi _ = 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 }); From 0c9ed78b5133286b01846ce3ac7b715e75429b99 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexandre=20Calv=C3=A3o?= Date: Thu, 15 Oct 2026 16:05:38 +0000 Subject: [PATCH 3/9] fix(pathfinder): avoid unsupported wasm32 world identity atomic --- .github/workflows/ci.yml | 3 +++ build.zig | 31 +++++++++++++++++++++++++++++++ src/nav/controller.zig | 11 +++++++---- tests/wasm/controller_compile.zig | 13 +++++++++++++ 4 files changed, 54 insertions(+), 4 deletions(-) create mode 100644 tests/wasm/controller_compile.zig diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 451047a..b636497 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -27,6 +27,9 @@ jobs: - name: Run zspec tests run: zig build spec + - name: Compile controller for wasm32 + run: zig build wasm-check + # Prepared endpoint integration depends on engine#916 until its release. # Pin the exact tested commit rather than following an unreleased branch. - name: Checkout engine position revision API diff --git a/build.zig b/build.zig index e98b05f..5d27245 100644 --- a/build.zig +++ b/build.zig @@ -74,4 +74,35 @@ pub fn build(b: *std.Build) void { const spec_step = b.step("spec", "Run zspec navigation tests"); spec_step.dependOn(&run_nav_tests.step); + + // Force controller lifecycle code generation for the threaded web target. + // This catches unsupported atomics without requiring the Emscripten SDK. + const wasm_target = b.resolveTargetQuery(.{ + .cpu_arch = .wasm32, + .os_tag = .freestanding, + .cpu_features_add = std.Target.wasm.featureSet(&.{ .atomics, .bulk_memory }), + }); + const wasm_core = b.dependency("labelle_core", .{ + .target = wasm_target, + .optimize = .ReleaseSmall, + }).module("labelle-core"); + const wasm_pathfinder = b.createModule(.{ + .root_source_file = b.path("src/root.zig"), + .target = wasm_target, + .optimize = .ReleaseSmall, + .imports = &.{ + .{ .name = "zig_utils", .module = zig_utils }, + .{ .name = "labelle-core", .module = wasm_core }, + }, + }); + const wasm_controller = b.addObject(.{ + .name = "wasm-controller-check", + .root_module = b.createModule(.{ + .root_source_file = b.path("tests/wasm/controller_compile.zig"), + .target = wasm_target, + .optimize = .ReleaseSmall, + .imports = &.{.{ .name = "pathfinder", .module = wasm_pathfinder }}, + }), + }); + b.step("wasm-check", "Compile controller lifecycle for wasm32").dependOn(&wasm_controller.step); } diff --git a/src/nav/controller.zig b/src/nav/controller.zig index 69f81f6..46b2ced 100644 --- a/src/nav/controller.zig +++ b/src/nav/controller.zig @@ -209,11 +209,14 @@ const EndpointSnapshot = struct { }; // Identity only: never used for selection order. A fresh controller lifetime // cannot accept publication metadata from an old world, even at the same address. -var next_preparation_world = std.atomic.Value(u64).init(0); +// Controller lifetimes are created/destroyed on the simulation thread. Future +// preparation workers consume snapshots; they must not construct controller state. +// Keep the full u64 identity range without requiring unsupported wasm32 atomics. +var next_preparation_world: u64 = 0; fn newPreparationWorld() u64 { - const previous = next_preparation_world.fetchAdd(1, .monotonic); - if (previous == std.math.maxInt(u64)) @panic("preparation world identity exhausted"); - return previous + 1; + if (next_preparation_world == std.math.maxInt(u64)) @panic("preparation world identity exhausted"); + next_preparation_world += 1; + return next_preparation_world; } /// Heap-allocated runtime state. Not exported directly — callers reach diff --git a/tests/wasm/controller_compile.zig b/tests/wasm/controller_compile.zig new file mode 100644 index 0000000..9716d5f --- /dev/null +++ b/tests/wasm/controller_compile.zig @@ -0,0 +1,13 @@ +//! Compile the real controller lifecycle for wasm32. No browser or SDK needed. +//! Exporting the identity forces code generation for State.init, unlike decl-only +//! checks which missed the unsupported u64 atomic in the preparation counter. +const std = @import("std"); +const pathfinder = @import("pathfinder"); + +export fn preparationWorldIdentity() u64 { + var memory: [32768]u8 = undefined; + var allocator = std.heap.FixedBufferAllocator.init(&memory); + var state = pathfinder.controller.State.init(allocator.allocator()); + defer state.deinit(); + return state.preparation_world; +} From d11e29750d4cd8bffc9fe800f715e3980506fc26 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexandre=20Calv=C3=A3o?= Date: Thu, 15 Oct 2026 16:27:06 +0000 Subject: [PATCH 4/9] perf(pathfinder): reuse certified nearest nodes during preparation --- .github/workflows/ci.yml | 4 +- src/nav/components.zig | 16 ++- src/nav/controller.zig | 133 +++++++++++++++++++++---- tests/integration/preparation_test.zig | 85 ++++++++++++++++ tests/nav/prepared_endpoint_test.zig | 2 +- 5 files changed, 218 insertions(+), 22 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b636497..9f39bf1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -30,13 +30,13 @@ 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 58c060d227f614b8476836b6d74b163a9be4f53d + git -C ../labelle-engine fetch --depth 1 origin c9dddffba8f78d9e1dbd5a81d34f5623b669a2e5 git -C ../labelle-engine checkout --detach FETCH_HEAD - name: Run engine integration tests 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 46b2ced..faa9857 100644 --- a/src/nav/controller.zig +++ b/src/nav/controller.zig @@ -190,6 +190,7 @@ pub const PreparationStats = struct { pending: usize = 0, selected: usize = 0, nearest_searches: usize = 0, + reused_endpoints: usize = 0, published: usize = 0, off_graph: usize = 0, discarded: usize = 0, @@ -232,6 +233,7 @@ pub const State = struct { 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. @@ -318,6 +320,7 @@ pub const State = struct { self.preparation_batch.deinit(self.allocator); self.preparation_snapshots.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); @@ -328,6 +331,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(); @@ -817,7 +821,7 @@ pub const Controller = struct { 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 cmn.current(endpointStamp(game, st, entity) orelse return false); + return preparedStampCurrent(game, st, entity, cmn); } fn preparedGraphCurrent(game: anytype, st: *State) bool { @@ -834,15 +838,29 @@ pub const Controller = struct { 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 { + return node < st.registered_entities.items.len and st.registered_entities.items[node] == 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.hasComponent(entity, MovementNode)) return registeredNode(game, st, entity); + 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.ready(endpointStamp(game, st, entity) orelse return null)) return null; - const node = registeredNode(game, st, @as(@TypeOf(entity), @intCast(cmn.node_entity))) orelse return null; - if (node != cmn.node_id) return null; - return node; + 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 @@ -1222,10 +1240,8 @@ 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)) continue; } st.preparation_stats.pending += 1; st.preparation_batch.consider(@intCast(entity)); @@ -1244,14 +1260,16 @@ 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; + st.preparation_stats.reused_endpoints += 1; + continue; + } } + st.preparation_stats.nearest_searches += 1; + snapshot.resolution = resolvePreparedNearest(st, snapshot.position); } st.preparation_stats.selected = st.preparation_batch.selected.items.len; st.preparation_stats.discarded = st.preparation_stats.selected - st.preparation_snapshots.items.len; @@ -1313,6 +1331,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, }; } @@ -1472,6 +1491,13 @@ 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; + st.registered_entities.resize(st.allocator, @as(usize, node_id) + 1) catch { + watches_ok = false; + continue; + }; + @memset(st.registered_entities.items[old_len..], null); + st.registered_entities.items[node_id] = entity_id; st.graph_inputs.put(st.allocator, node_id, .{ .position = pos, .stair = is_stair, @@ -1878,6 +1904,79 @@ 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 }; +} + +/// 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(st: *State, pos: Position) ?ClosestMovementNode.Resolution { + if (!std.math.isFinite(pos.x) or !std.math.isFinite(pos.y)) return null; + var best: ?NodeId = 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 (0..st.pf.graph.totalSlots()) |i| { + const node: NodeId = @intCast(i); + if (st.pf.graph.isRemoved(node)) continue; + const point = st.pf.graph.getPosition(node); + 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 entity = st.node_entities.get(node) orelse 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 = entity, + .node_id = node, + .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; diff --git a/tests/integration/preparation_test.zig b/tests/integration/preparation_test.zig index 823ed82..75cced3 100644 --- a/tests/integration/preparation_test.zig +++ b/tests/integration/preparation_test.zig @@ -362,3 +362,88 @@ test "known docking placement validates world geometry and is invalidated by anc 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)); +} 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" { From 9507fcdb6c9a1de7c537a31deab18e5910931d91 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexandre=20Calv=C3=A3o?= Date: Thu, 15 Oct 2026 17:25:48 +0000 Subject: [PATCH 5/9] perf(pathfinder): prepare pending domains without per-frame discovery --- .github/workflows/ci.yml | 2 +- src/nav/controller.zig | 74 ++++++++++++- tests/integration/preparation_test.zig | 143 +++++++++++++++++++++++++ 3 files changed, 217 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9f39bf1..ca1b784 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -36,7 +36,7 @@ jobs: 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 c9dddffba8f78d9e1dbd5a81d34f5623b669a2e5 + git -C ../labelle-engine fetch --depth 1 origin 241556dc0263c975bbd4de1257ed6a6c8e3e22e5 git -C ../labelle-engine checkout --detach FETCH_HEAD - name: Run engine integration tests diff --git a/src/nav/controller.zig b/src/nav/controller.zig index faa9857..d1ea5ee 100644 --- a/src/nav/controller.zig +++ b/src/nav/controller.zig @@ -187,6 +187,7 @@ 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, @@ -227,6 +228,9 @@ pub const State = struct { pf: Pf, preparation_world: u64, preparation_enabled: bool = false, + 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 = .{}, @@ -808,6 +812,7 @@ pub const Controller = struct { var cmn = if (ecs.getComponent(entity, ClosestMovementNode)) |old| old.* else ClosestMovementNode{}; if (!cmn.publish(stamp, stamp, .{ .node_entity = @intCast(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; } @@ -1023,6 +1028,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; + if (st.preparation_enabled) game.active_world.position_revisions.markPending(entity_id); ecs.addComponent(target_entity, components.ClosestMovementNode{ .node_entity = ne, .node_id = nid, @@ -1132,6 +1138,7 @@ pub const Controller = struct { 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); @@ -1222,6 +1229,66 @@ pub const Controller = struct { st.preparation_enabled = true; } + /// 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; + } + /// Serial 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. @@ -1241,7 +1308,10 @@ pub const Controller = struct { st.preparation_stats.discovered += 1; if (!ecs.entityExists(entity) or ecs.hasComponent(entity, MovementNode)) continue; if (ecs.getComponent(entity, ClosestMovementNode)) |cmn| { - if (preparedStampCurrent(game, st, entity, cmn)) continue; + 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)); @@ -1311,6 +1381,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; @@ -2038,6 +2109,7 @@ 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) game.active_world.position_revisions.markPending(entity_id); // CMN refresh replaces the old game-side arrival → NeedsClosestNode // marker → next-tick resolve round-trip: the plugin owns the CMN diff --git a/tests/integration/preparation_test.zig b/tests/integration/preparation_test.zig index 75cced3..c1f7199 100644 --- a/tests/integration/preparation_test.zig +++ b/tests/integration/preparation_test.zig @@ -447,3 +447,146 @@ test "known placements and changed graphs cannot reuse a nearest certificate" { 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); +} From fb32341efa3d6e09715a0097c4aa27a846b80a49 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexandre=20Calv=C3=A3o?= Date: Thu, 15 Oct 2026 17:29:18 +0000 Subject: [PATCH 6/9] test(pathfinder): compare pending preparation with full-view reference --- tests/integration/preparation_test.zig | 55 ++++++++++++++++++++++++++ 1 file changed, 55 insertions(+) diff --git a/tests/integration/preparation_test.zig b/tests/integration/preparation_test.zig index c1f7199..352f593 100644 --- a/tests/integration/preparation_test.zig +++ b/tests/integration/preparation_test.zig @@ -590,3 +590,58 @@ test "pending discovery retries after allocation failure and controller replacem 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); + 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)); + } + } +} From 944d754326a1dfa497696d517f95747be4ab3b76 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexandre=20Calv=C3=A3o?= Date: Thu, 15 Oct 2026 17:53:53 +0000 Subject: [PATCH 7/9] feat: finish prepared queries and deterministic snapshot worker execution --- .github/workflows/ci.yml | 6 +- src/nav/controller.zig | 112 +++++++++++++++++++++---- tests/integration/preparation_test.zig | 88 +++++++++++++++++++ 3 files changed, 187 insertions(+), 19 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ca1b784..3d5106f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -36,8 +36,12 @@ jobs: 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 241556dc0263c975bbd4de1257ed6a6c8e3e22e5 + git -C ../labelle-engine fetch --depth 1 origin 2c4b0c17a669e520ff01f409714195d3b913ba57 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/src/nav/controller.zig b/src/nav/controller.zig index d1ea5ee..166ca1a 100644 --- a/src/nav/controller.zig +++ b/src/nav/controller.zig @@ -192,6 +192,7 @@ pub const PreparationStats = struct { 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, @@ -203,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, @@ -228,6 +231,9 @@ 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, @@ -323,6 +329,7 @@ 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(); @@ -792,6 +799,45 @@ pub const Controller = struct { 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. @@ -1028,8 +1074,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; - if (st.preparation_enabled) game.active_world.position_revisions.markPending(entity_id); - 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), @@ -1127,11 +1172,9 @@ 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); @@ -1316,6 +1359,16 @@ pub const Controller = struct { st.preparation_stats.pending += 1; st.preparation_batch.consider(@intCast(entity)); } + 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; + } 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. @@ -1334,13 +1387,17 @@ pub const Controller = struct { 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; - snapshot.resolution = resolvePreparedNearest(st, snapshot.position); } + 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; @@ -1996,22 +2053,31 @@ fn reusePreparedResolution(st: *State, cmn: *const ClosestMovementNode, stamp: C 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(st: *State, pos: Position) ?ClosestMovementNode.Resolution { +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: ?NodeId = 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 (0..st.pf.graph.totalSlots()) |i| { - const node: NodeId = @intCast(i); - if (st.pf.graph.isRemoved(node)) continue; - const point = st.pf.graph.getPosition(node); + 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) { @@ -2036,13 +2102,13 @@ fn resolvePreparedNearest(st: *State, pos: Position) ?ClosestMovementNode.Resolu } const node = best orelse return null; if (best_distance > MAX_NODE_SNAP_DISTANCE) return null; - const entity = st.node_entities.get(node) orelse 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 = entity, - .node_id = node, + .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 }, }; @@ -2109,7 +2175,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) game.active_world.position_revisions.markPending(entity_id); + 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 diff --git a/tests/integration/preparation_test.zig b/tests/integration/preparation_test.zig index 352f593..6211ea5 100644 --- a/tests/integration/preparation_test.zig +++ b/tests/integration/preparation_test.zig @@ -610,6 +610,7 @@ test "pending and full-view preparation publish identical frame outcomes" { 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) { @@ -645,3 +646,90 @@ test "pending and full-view preparation publish identical frame outcomes" { } } } + +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)); +} From ea91ff0b76c1753819bb03324897d4a65b501bd7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexandre=20Calv=C3=A3o?= Date: Thu, 15 Oct 2026 21:46:01 +0000 Subject: [PATCH 8/9] feat: maintain prepared endpoints from walker routes --- .github/workflows/ci.yml | 2 +- build.zig.zon | 2 +- src/nav/controller.zig | 77 +++++++++++++++++++++++++- src/nav/engine.zig | 4 ++ tests/integration/preparation_test.zig | 70 +++++++++++++++++++++++ 5 files changed, 150 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3d5106f..e7ea22f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -36,7 +36,7 @@ jobs: 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 2c4b0c17a669e520ff01f409714195d3b913ba57 + git -C ../labelle-engine fetch --depth 1 origin 21ea60c67038416c007e308814c137358104353d git -C ../labelle-engine checkout --detach FETCH_HEAD - name: Run engine integration tests diff --git a/build.zig.zon b/build.zig.zon index 0f42101..2e11fa6 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.1", .fingerprint = 0x15f62ccc39d14685, .minimum_zig_version = "0.16.0", .dependencies = .{ diff --git a/src/nav/controller.zig b/src/nav/controller.zig index 166ca1a..4912117 100644 --- a/src/nav/controller.zig +++ b/src/nav/controller.zig @@ -853,15 +853,72 @@ pub const Controller = struct { 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 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 = @intCast(node_entity), .node_id = node, .distance = distance_to_node })) return false; + 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. @@ -1332,7 +1389,7 @@ pub const Controller = struct { return result; } - /// Serial snapshot + compute phase. Feed a union of game-owned domain views + /// 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 { @@ -1749,7 +1806,16 @@ 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) { + if (ecs.getComponent(entity, ClosestMovementNode)) |cmn| { + const known: Entity = @intCast(cmn.node_entity); + if (publishKnownEndpoint(game, entity, known) catch false) + publishAttachedEndpoints(game, entity, known); + } + } + }, } } @@ -2272,6 +2338,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 6211ea5..6b51858 100644 --- a/tests/integration/preparation_test.zig +++ b/tests/integration/preparation_test.zig @@ -733,3 +733,73 @@ test "legacy navigation target lookup preserves stale matching debug records" { _ = 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); +} From 2e3d954856b2f399c07009c9d975753d2cb550ca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexandre=20Calv=C3=A3o?= Date: Mon, 19 Oct 2026 14:06:08 +0000 Subject: [PATCH 9/9] fix: refresh direct mover anchors and tolerate missing registration index --- build.zig.zon | 2 +- src/nav/controller.zig | 69 ++++++++++++++++++-------- tests/integration/preparation_test.zig | 43 ++++++++++++++++ 3 files changed, 91 insertions(+), 23 deletions(-) diff --git a/build.zig.zon b/build.zig.zon index 2e11fa6..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.3.0-rc.1", + .version = "4.3.0-rc.2", .fingerprint = 0x15f62ccc39d14685, .minimum_zig_version = "0.16.0", .dependencies = .{ diff --git a/src/nav/controller.zig b/src/nav/controller.zig index 4912117..d77aa19 100644 --- a/src/nav/controller.zig +++ b/src/nav/controller.zig @@ -856,6 +856,39 @@ pub const Controller = struct { 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); @@ -954,7 +987,11 @@ pub const Controller = struct { } fn registeredIdentity(st: *State, node: NodeId, entity: u64) bool { - return node < st.registered_entities.items.len and st.registered_entities.items[node] == entity and !st.pf.graph.isRemoved(node); + 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 { @@ -1416,16 +1453,7 @@ pub const Controller = struct { st.preparation_stats.pending += 1; st.preparation_batch.consider(@intCast(entity)); } - 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; - } + 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. @@ -1677,12 +1705,13 @@ pub const Controller = struct { }; if (st.preparation_enabled) { const old_len = st.registered_entities.items.len; - st.registered_entities.resize(st.allocator, @as(usize, node_id) + 1) catch { - watches_ok = false; - continue; - }; - @memset(st.registered_entities.items[old_len..], null); - st.registered_entities.items[node_id] = entity_id; + 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, @@ -1809,11 +1838,7 @@ pub const Controller = struct { .move => |next| { game.setPosition(entity, next); if (st.preparation_enabled) { - if (ecs.getComponent(entity, ClosestMovementNode)) |cmn| { - const known: Entity = @intCast(cmn.node_entity); - if (publishKnownEndpoint(game, entity, known) catch false) - publishAttachedEndpoints(game, entity, known); - } + publishDirectEndpoint(game, st, entity) catch {}; } }, } diff --git a/tests/integration/preparation_test.zig b/tests/integration/preparation_test.zig index 6b51858..806bf45 100644 --- a/tests/integration/preparation_test.zig +++ b/tests/integration/preparation_test.zig @@ -803,3 +803,46 @@ test "direct walks retain a validated known anchor and dropping invalidates atta _ = 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); +}