Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,23 @@ pathfinder.Controller.findClosestNode(game, pos);
pathfinder.Controller.targetPosition(game, entity); // CMN-preferred nav target for an entity
```

For a synchronous destination scan, reuse the origin resolution:

```zig
var distances = pathfinder.Controller.walkDistancesFrom(game, source);
// Inside a read-only candidate loop:
const cost = distances.to(destination);
```

The first candidate resolves the origin's world position and its validated
nearest node; subsequent candidates reuse that result. Empty scans do no
resolution work. Targets still resolve live, and distances retain `walkDistance`'s
pre-graph Euclidean and unreachable-infinity behavior. Discard the query before
moving/reparenting the origin or its ancestors, changing the graph or world, or
advancing the simulation. It is not a cross-frame cache or a thread-safe handle.
The query uses the existing position-validated node cache; the ECS
`ClosestMovementNode` component alone does not supply that freshness contract.

### Events (the only outbound channel — subscribe from game hooks)

| Event | Payload | Fires when |
Expand Down
2 changes: 1 addition & 1 deletion build.zig.zon
Original file line number Diff line number Diff line change
Expand Up @@ -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.1.0",
.version = "4.2.0",
.fingerprint = 0x15f62ccc39d14685,
.minimum_zig_version = "0.16.0",
.dependencies = .{
Expand Down
123 changes: 120 additions & 3 deletions src/nav/controller.zig
Original file line number Diff line number Diff line change
Expand Up @@ -735,9 +735,10 @@ pub const Controller = struct {
/// Intended for sibling-plugin callers (e.g. `production.Controller`'s
/// scheduler) that need a CMN-graph-aware distance instead of the
/// raw Euclidean `getPosition` delta. Uses Floyd-Warshall's
/// pre-computed cost so each call is O(1) once the closest-node
/// lookups (linear scan over registered MN nodes) finish — opportunity
/// for entity-to-NodeId caching if this becomes a hot path.
/// pre-computed cost after resolving world positions and validating the
/// entity-keyed nearest-node cache. Only cache misses scan movement nodes.
/// For multiple targets with an unchanged origin, use walkDistancesFrom
/// to avoid repeating the origin's world-position and cache lookup.
pub fn distance(game: anytype, from_entity: anytype, to_entity: anytype) ?f32 {
const st = findState(game) orelse return null;
// `getWorldPosition` (not `getPosition`) so parented
Expand Down Expand Up @@ -945,6 +946,37 @@ pub const Controller = struct {
return distance(game, from_entity, to_entity) orelse std.math.inf(f32);
}

/// Distances from one origin during a synchronous, read-only candidate scan.
/// Resolves the origin lazily on the first candidate, then reuses its world
/// position and validated nearest node. Uses the SAME nearest-node cache
/// and fallback rules as walkDistance, not an unvalidated CMN component.
///
/// Discard before moving/reparenting the origin (or its ancestors), changing
/// the graph, switching worlds, or advancing the simulation. Targets are
/// resolved live on each call. Do not retain this query across frames.
pub fn walkDistancesFrom(game: anytype, from_entity: anytype) WalkDistances(@TypeOf(game), @TypeOf(from_entity)) {
return .{ .game = game, .from_entity = from_entity };
}

pub fn WalkDistances(comptime Game: type, comptime Entity: type) type {
return struct {
game: Game,
from_entity: Entity,
origin: ?WalkOrigin = null,

pub fn to(self: *@This(), target_entity: anytype) f32 {
if (self.origin == null) {
self.origin = WalkOrigin.resolve(
findState(self.game),
@intCast(self.from_entity),
self.game.getWorldPosition(self.from_entity),
);
}
return self.origin.?.distanceTo(self.game, target_entity);
}
};
}

fn euclideanBetween(game: anytype, a: anytype, b: anytype) f32 {
const pa = game.getWorldPosition(a);
const pb = game.getWorldPosition(b);
Expand Down Expand Up @@ -1446,6 +1478,46 @@ pub fn pruneNearestNodeCacheBy(st: *State, probe: anytype) void {
}
}

/// Private resolved origin for a read-only scan. Both successful and failed
/// resolutions are reused only for the lifetime of that scan; no new persistent
/// cache or invalidation policy is introduced.
const WalkOrigin = union(enum) {
euclidean: Position,
graph: struct { state: *State, node: NodeId },
off_graph,

fn resolve(state: ?*State, entity_id: u64, position: Position) WalkOrigin {
const st = state orelse return .{ .euclidean = position };
if (st.node_entities.count() == 0) return .{ .euclidean = position };
const node = findNearestNodeForEntity(st, entity_id, position.x, position.y) orelse return .off_graph;
return .{ .graph = .{ .state = st, .node = node } };
}

fn distanceTo(self: WalkOrigin, game: anytype, entity: anytype) f32 {
// Match distance's early return: an off-graph origin never resolves
// the target, so it must not populate the target's cache either.
if (self == .off_graph) return std.math.inf(f32);
return self.distanceToPosition(@intCast(entity), game.getWorldPosition(entity));
}

fn distanceToPosition(self: WalkOrigin, entity_id: u64, position: Position) f32 {
return switch (self) {
.off_graph => std.math.inf(f32),
.euclidean => |origin| blk: {
const dx = origin.x - position.x;
const dy = origin.y - position.y;
break :blk @sqrt(dx * dx + dy * dy);
},
.graph => |origin| blk: {
const target = findNearestNodeForEntity(origin.state, entity_id, position.x, position.y) orelse
break :blk std.math.inf(f32);
const d = origin.state.pf.distance(origin.node, target);
break :blk if (d == types.INF) std.math.inf(f32) else d;
},
};
}
};

/// Entity-keyed wrapper over `findNearestNodeInState` that memoises
/// per-entity lookups across calls. Correct for both stationary and
/// moving entities — see `State.nearest_node_cache` for the
Expand Down Expand Up @@ -1871,3 +1943,48 @@ test "emitGameEvent no-ops when the game has no GameEvents decl" {
.node_entity = @as(u32, 2),
});
}

test "walk origin preserves pre-graph Euclidean fallback and graph unreachable semantics" {
const pos = Position{ .x = 0, .y = 0 };
const target = Position{ .x = 3, .y = 4 };
try std.testing.expectEqual(@as(f32, 5), WalkOrigin.resolve(null, 10, pos).distanceToPosition(20, target));
var st = State.init(std.testing.allocator);
defer st.deinit();
try std.testing.expectEqual(@as(f32, 5), WalkOrigin.resolve(&st, 10, pos).distanceToPosition(20, target));
const a = try st.pf.addNode(pos, false);
const b = try st.pf.addNode(.{ .x = 60, .y = 0 }, false);
const disconnected = try st.pf.addNode(.{ .x = 300, .y = 0 }, false);
try st.node_entities.put(st.allocator, a, 100);
try st.node_entities.put(st.allocator, b, 101);
try st.node_entities.put(st.allocator, disconnected, 102);
const origin = WalkOrigin.resolve(&st, 10, pos);
try std.testing.expectEqual(@as(f32, 60), origin.distanceToPosition(20, .{ .x = 60, .y = 0 }));
try std.testing.expect(std.math.isInf(origin.distanceToPosition(21, .{ .x = 300, .y = 0 })));
try std.testing.expect(std.math.isInf(origin.distanceToPosition(22, .{ .x = 1000, .y = 0 })));
try std.testing.expect(std.math.isInf(WalkOrigin.resolve(&st, 30, .{ .x = 1000, .y = 0 }).distanceToPosition(20, target)));
}

test "walk origin refreshes between scans after movement and graph rebuild" {
var st = State.init(std.testing.allocator);
defer st.deinit();
const a = try st.pf.addNode(.{ .x = 0, .y = 0 }, false);
const b = try st.pf.addNode(.{ .x = 60, .y = 0 }, false);
try st.node_entities.put(st.allocator, a, 100);
try st.node_entities.put(st.allocator, b, 101);
const first = WalkOrigin.resolve(&st, 10, .{ .x = 0, .y = 0 });
try std.testing.expectEqual(@as(f32, 60), first.distanceToPosition(20, .{ .x = 60, .y = 0 }));
// Same entity now has a different world position (also covers a moved
// parent once the caller resolves the world position).
const moved = WalkOrigin.resolve(&st, 10, .{ .x = 60, .y = 0 });
try std.testing.expectEqual(@as(f32, 0), moved.distanceToPosition(20, .{ .x = 60, .y = 0 }));
// A failed result is scoped to one scan; it must not poison later scans.
const missing = WalkOrigin.resolve(&st, 30, .{ .x = 1000, .y = 0 });
try std.testing.expect(missing == .off_graph);
const returned = WalkOrigin.resolve(&st, 30, .{ .x = 0, .y = 0 });
try std.testing.expectEqual(@as(f32, 60), returned.distanceToPosition(20, .{ .x = 60, .y = 0 }));
st.reset(st.allocator);
const replacement = try st.pf.addNode(.{ .x = 60, .y = 0 }, false);
try st.node_entities.put(st.allocator, replacement, 200);
const rebuilt = WalkOrigin.resolve(&st, 10, .{ .x = 60, .y = 0 });
try std.testing.expectEqual(@as(f32, 0), rebuilt.distanceToPosition(20, .{ .x = 60, .y = 0 }));
}
Loading