diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 44b1d96..e1287d2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -4,7 +4,6 @@ on: push: branches: [main] pull_request: - branches: [main] jobs: test: @@ -27,3 +26,18 @@ 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 + 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 checkout --detach FETCH_HEAD + + - name: Run engine integration tests + run: zig build test --build-file tests/integration/build.zig --summary all 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/rfc/prepared-endpoint-implementation.md b/rfc/prepared-endpoint-implementation.md index 9b3f879..1d9b570 100644 --- a/rfc/prepared-endpoint-implementation.md +++ b/rfc/prepared-endpoint-implementation.md @@ -2,8 +2,8 @@ Design: [Flying Platform RFC #1046](https://github.com/moca-tecnologia/flying-platform-labelle/pull/1046). -This is an additive first slice, not the completed prepared matching API. -Existing navigation and game matching behavior is unchanged. +This is a staged implementation, not the completed prepared matching API. +Existing matching APIs remain available. The serial controller phase is opt-in. ## Implemented @@ -81,3 +81,70 @@ budget, empty views, OOM and every ordering of a small candidate set. There are now 81 navigation specs. This helper does not yet discover participants, take snapshots, search nodes or publish CMNs. Controller integration remains required; only a pending predicate backed by shared freshness may feed this selector. + +## Serial controller phase + +The controller now owns batch scratch, snapshots and computed results. Opt in with +`Controller.enableEndpointPreparation(game)` before `advance`. Opt-in graph +validation detects changes in node identity/registration, node world position +(including ancestor motion), stairs and authored links even when node count is +unchanged. The next advance rebuilds the graph when those inputs differ. Missing +metadata or an unavailable graph defers preparation normally. Legacy-only callers +retain the existing graph handling. + +After movement, feed the union of the game's matching-participant views to +`prepareEndpointBatch(game, &iterator, budget)`. The union may contain duplicate +entities. Current CMNs and current off-graph results are skipped; MovementNode +entities never enter this ordinary-entity batch. The selected ordinary entities +receive revision tracking through engine#916. All positions are snapshotted before +nearest-node computation, and no CMN is published by this call. + +Deinitialize the iterator before the explicit publication phase: + +```zig +{ + var participants = game.active_world.ecs_backend.view(.{MatchingParticipant}, .{}); + defer participants.deinit(); + _ = try pathfinder.Controller.prepareEndpointBatch(game, &participants, budget); +} +const stats = pathfinder.Controller.publishEndpointBatch(game); +``` + +`MatchingParticipant` is an example domain marker, not a new mandatory prefab +component. The game may instead provide an iterator joining its existing domain +views. Call once per frame for the complete participant set; separate per-domain +calls would consume separate budgets and distort rotation. No initial budget is +chosen here: measure discovery, snapshots, searches, graph validation and +publication together before choosing it. + +Publication validates the controller/world lifetime, full entity handle, position +revision, graph epoch and graph inputs. It publishes in deterministic selection +order and rejects obsolete results. Rejected results consume their turn, preserving +fairness. `cancelEndpointBatch` discards an unfinished batch without consuming the +turn. A second prepare before publish/cancel returns `PreparationInProgress`; +repeated publication is a no-op. World/controller teardown frees outstanding data. + +This is synchronous serial execution. The compute phase reads the current graph +on the main thread; it does not own the immutable graph copy required for worker +threads. These APIs must not be moved onto background threads. Call prepare and +publish in the same logical frame; no scheduler callback should run between them. + +Opt-in epoch sweeping invalidates CMNs in place, preserving last-known node data. +Legacy `targetPosition` does not reuse those stale records or completed off-graph +records as a live navigation cache. Other legacy CMN writers can still replace a +record with pending metadata; reconciling those writers and known-node updates +remains part of migration. + +Stats expose discovered/pending iterator entries (including duplicate membership), +selected entities, actual nearest-node searches, published/off-graph results and +discarded results. Scratch capacities are reused. OOM before publication exposes +no partial batch and does not consume the cursor; revision registrations made +before an allocation error are harmless and may be reused. Graph-input validation +and discovery are not bounded by the nearest-search budget. + +**Not enabled in the game yet:** no gameplay query is switched, and no entity-level +prepared distance API is introduced by this phase. Before rollout, finish the raw +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. diff --git a/src/nav/controller.zig b/src/nav/controller.zig index 4854f84..452a02a 100644 --- a/src/nav/controller.zig +++ b/src/nav/controller.zig @@ -185,11 +185,52 @@ pub const ControllerState = struct { state_ptr: usize = 0, }; +pub const PreparationStats = struct { + discovered: usize = 0, + pending: usize = 0, + selected: usize = 0, + nearest_searches: usize = 0, + published: usize = 0, + off_graph: usize = 0, + discarded: usize = 0, + graph_ready: bool = false, +}; + +const GraphInput = struct { + position: Position, + stair: bool, + link: ?MovementLink, +}; +const EndpointSnapshot = struct { + entity: u64, + stamp: ClosestMovementNode.Stamp, + position: Position, + resolution: ?ClosestMovementNode.Resolution = null, +}; +// Identity only: never used for selection order. A fresh controller lifetime +// cannot accept publication metadata from an old world, even at the same address. +// 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 { + 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 /// it through the Controller's methods, which look up the singleton /// `ControllerState` component and cast `state_ptr` back to `*State`. pub const State = struct { pf: Pf, + preparation_world: u64, + preparation_enabled: bool = false, + preparation_in_flight: bool = false, + preparation_stats: PreparationStats = .{}, + preparation_batch: @import("preparation_batch.zig").PreparationBatch = .{}, + preparation_snapshots: std.ArrayListUnmanaged(EndpointSnapshot) = .empty, + graph_inputs: std.AutoHashMapUnmanaged(u32, GraphInput) = .empty, allocator: std.mem.Allocator, /// Node-ID → entity lookup. Iteration order is deterministic /// (insertion order) so simulation replay is stable. @@ -260,6 +301,7 @@ pub const State = struct { // few appends grow it lazily, never a correctness issue. pending.ensureTotalCapacity(allocator, PENDING_INITIAL_CAPACITY) catch {}; return .{ + .preparation_world = newPreparationWorld(), .pf = Pf.init(allocator, .{ .max_vertical_distance = MAX_VERTICAL_DISTANCE, .max_horizontal_distance = MAX_HORIZONTAL_DISTANCE, @@ -272,6 +314,9 @@ pub const State = struct { } pub fn deinit(self: *State) void { + self.preparation_batch.deinit(self.allocator); + self.preparation_snapshots.deinit(self.allocator); + self.graph_inputs.deinit(self.allocator); self.nearest_node_cache.deinit(); self.node_entities.deinit(self.allocator); self.pending_entities.deinit(self.allocator); @@ -281,6 +326,7 @@ pub const State = struct { } fn reset(self: *State, allocator: std.mem.Allocator) void { + self.graph_inputs.clearRetainingCapacity(); self.pf.deinit(); self.node_entities.deinit(self.allocator); self.nearest_node_cache.deinit(); @@ -861,7 +907,7 @@ pub const Controller = struct { // Fast path: a populated CMN (set by a prior nav, or by the // lazy resolve below on an earlier call). if (ecs.getComponent(target_entity, components.ClosestMovementNode)) |cmn| { - if (cmn.node_entity != 0) { + if (cmn.node_entity != 0 and legacyCmnUsable(game, target_entity, cmn.*)) { const node_entity: Entity = @intCast(cmn.node_entity); return game.getWorldPosition(node_entity); } @@ -1074,6 +1120,148 @@ pub const Controller = struct { emitGameEvent(game, "pathfinder__node_removed", .{ .node_id = node_id }); } + /// Opt in before advance. Graph-input validation and metadata are only + /// maintained for consumers using prepared endpoints. No game matching API + /// is changed by this opt-in. Existing worlds rebuild once on next advance. + pub fn enableEndpointPreparation(game: anytype) void { + const st = findState(game) orelse return; + st.preparation_enabled = true; + } + + /// 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. + pub fn prepareEndpointBatch(game: anytype, participants: anytype, budget: usize) !PreparationStats { + const st = findState(game) orelse return .{}; + if (st.preparation_in_flight) return error.PreparationInProgress; + st.preparation_enabled = true; + st.preparation_stats = .{}; + st.preparation_snapshots.clearRetainingCapacity(); + try st.preparation_batch.begin(st.allocator, budget); + if (budget == 0 or !graphInputsCurrent(game, st)) return st.preparation_stats; + if (!st.pf.prepareDistanceTable()) return st.preparation_stats; + st.preparation_stats.graph_ready = true; + try st.preparation_snapshots.ensureTotalCapacityPrecise(st.allocator, budget); + const ecs = &game.active_world.ecs_backend; + 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; + } + } + st.preparation_stats.pending += 1; + st.preparation_batch.consider(@intCast(entity)); + } + 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. + for (st.preparation_batch.selected.items) |id| { + const entity: Entity = @intCast(id); + _ = try game.trackPositionRevision(entity); + const stamp = endpointStamp(game, st, entity) orelse continue; + st.preparation_snapshots.appendAssumeCapacity(.{ + .entity = id, + .stamp = stamp, + .position = game.getWorldPosition(entity), + }); + } + 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) }; + } + } + 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; + return st.preparation_stats; + } + + /// Abort before publication (for example when cancelling a frame). No CMNs + /// were exposed and the continuation cursor does not consume a turn. + pub fn cancelEndpointBatch(game: anytype) void { + const st = findState(game) orelse return; + st.preparation_in_flight = false; + st.preparation_snapshots.clearRetainingCapacity(); + st.preparation_batch.selected.clearRetainingCapacity(); + st.preparation_batch.budget = 0; + st.preparation_stats = .{}; + } + + /// Same-frame publication barrier. Validate world, entity, transform and + /// graph inputs, then publish in the selected order. No asynchronous work + /// exists in this implementation; no result is exposed during computation. + pub fn publishEndpointBatch(game: anytype) PreparationStats { + const st = findState(game) orelse return .{}; + if (!st.preparation_in_flight) return .{}; + defer { + st.preparation_in_flight = false; + st.preparation_snapshots.clearRetainingCapacity(); + st.preparation_batch.finish(); + } + const graph_ready = graphInputsCurrent(game, st) and !st.pf.graph.dirty and st.pf.fw != null; + st.preparation_stats.graph_ready = graph_ready; + const Entity = @TypeOf(game.*).EntityType; + const ecs = &game.active_world.ecs_backend; + for (st.preparation_snapshots.items) |snapshot| { + const entity: Entity = @intCast(snapshot.entity); + if (graph_ready) { + if (endpointStamp(game, st, entity)) |stamp| { + if (!ecs.hasComponent(entity, MovementNode)) { + var cmn = if (ecs.getComponent(entity, ClosestMovementNode)) |old| old.* else ClosestMovementNode{}; + if (cmn.publish(snapshot.stamp, stamp, snapshot.resolution)) { + ecs.addComponent(entity, cmn); + st.preparation_stats.published += 1; + if (snapshot.resolution == null) st.preparation_stats.off_graph += 1; + continue; + } + } + } + } + st.preparation_stats.discarded += 1; + } + return st.preparation_stats; + } + + fn endpointStamp(game: anytype, st: *State, entity: anytype) ?ClosestMovementNode.Stamp { + if (!game.active_world.ecs_backend.entityExists(entity)) return null; + const revision = game.positionRevision(entity) orelse return null; + return .{ + .world_revision = st.preparation_world, + // The complete ECS handle, including generation, is the identity. + .entity_id = @intCast(entity), + .position_revision = revision, + .graph_revision = st.graph_epoch, + }; + } + + /// Runs only in preparation/advance, never in a distance query. Compare + /// 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_inputs.count() != st.node_entities.count()) return false; + const ecs = &game.active_world.ecs_backend; + var view = ecs.view(.{MovementNode}, .{}); + defer view.deinit(); + var count: usize = 0; + while (view.next()) |entity| { + const node = ecs.getComponent(entity, MovementNode).?.node_id; + if (st.node_entities.get(node) != @as(u64, @intCast(entity)) or st.pf.graph.isRemoved(node)) return false; + const old = st.graph_inputs.get(node) orelse return false; + const pos = game.getWorldPosition(entity); + const link: ?MovementLink = if (ecs.getComponent(entity, MovementLink)) |value| value.* else null; + if (!std.meta.eql(old.position, pos) or old.stair != ecs.hasComponent(entity, MovementStair) or !std.meta.eql(old.link, link)) return false; + count += 1; + } + return count == st.node_entities.count(); + } + /// Per-frame advancement of in-flight navigations. /// /// Not auto-called — a one-line plugin-shipped script at @@ -1190,6 +1378,13 @@ pub const Controller = struct { st.node_entities.put(st.allocator, node_id, entity_id) catch |err| { log.err("failed to track node {d}: {}", .{ node_id, err }); }; + if (st.preparation_enabled) { + st.graph_inputs.put(st.allocator, node_id, .{ + .position = pos, + .stair = is_stair, + .link = if (ecs.getComponent(entity, MovementLink)) |link| link.* else null, + }) catch {}; // Missing metadata prevents publication; retry on advance. + } } // Second pass, once every node is in: authored links. Adding them @@ -1266,7 +1461,8 @@ pub const Controller = struct { const tracked: u32 = @intCast(st.node_entities.count()); const count_changed = live != tracked; const stale = st.nodes_registered and isGraphStale(game, st); - if (!count_changed and !stale) return; + const inputs_changed = st.preparation_enabled and live > 0 and !graphInputsCurrent(game, st); + if (!count_changed and !stale and !inputs_changed) return; st.reset(st.allocator); buildGraphIntoState(game, st); @@ -1365,16 +1561,21 @@ pub const Controller = struct { } } - /// Epoch-change CMN invalidation (moved in from the game-side - /// dispatch in v4): when the graph is rebuilt, every cached - /// `ClosestMovementNode` may point at a node that no longer exists - /// or is no longer optimal. Drop them all; arrivals and - /// `requestRepath` re-resolve lazily. + /// Preserve the last node for debugging after opting into preparation. + /// Legacy-only consumers retain their existing destructive sweep behavior. fn sweepCmnOnEpochChange(st: *State, game: anytype) void { if (st.graph_epoch == st.last_cmn_epoch) return; const Entity = @TypeOf(game.*).EntityType; const ecs = &game.active_world.ecs_backend; - + if (st.preparation_enabled) { + var view = ecs.view(.{ClosestMovementNode}, .{}); + defer view.deinit(); + while (view.next()) |entity| { + if (ecs.getComponent(entity, ClosestMovementNode)) |cmn| cmn.invalidate(.graph); + } + st.last_cmn_epoch = st.graph_epoch; + return; + } var holders: std.ArrayListUnmanaged(u64) = .empty; defer holders.deinit(st.allocator); var collected_all = true; @@ -1383,20 +1584,33 @@ pub const Controller = struct { defer view.deinit(); while (view.next()) |entity| { holders.append(st.allocator, @intCast(entity)) catch { - // OOM: don't advance the epoch marker — next tick - // re-runs the whole sweep. collected_all = false; break; }; } } - for (holders.items) |id| { - const entity: Entity = @intCast(id); - ecs.removeComponent(entity, ClosestMovementNode); - } + for (holders.items) |id| ecs.removeComponent(@as(Entity, @intCast(id)), ClosestMovementNode); if (collected_all) st.last_cmn_epoch = st.graph_epoch; } + /// A retained prepared/sweep-invalidated CMN is not the legacy navigation + /// cache. Do not let targetPosition dereference its stale debugging node. + fn legacyCmnUsable(game: anytype, entity: anytype, cmn: ClosestMovementNode) bool { + if (!cmn.stamp.valid() and cmn.invalidation == .none) return true; + if (comptime @hasDecl(@TypeOf(game.*), "positionRevision")) { + const st = findState(game) orelse return false; + const stamp = endpointStamp(game, st, entity) orelse return false; + if (!cmn.ready(stamp) or st.pf.graph.isRemoved(cmn.node_id)) return false; + if (st.node_entities.get(cmn.node_id) != cmn.node_entity) return false; + const node: @TypeOf(game.*).EntityType = @intCast(cmn.node_entity); + const ecs = &game.active_world.ecs_backend; + if (!ecs.entityExists(node)) return false; + const registration = ecs.getComponent(node, MovementNode) orelse return false; + return registration.node_id == cmn.node_id; + } + return false; + } + /// Drain the pending ring of entities whose pathfinder navigation /// has settled (arrived, destroyed, or cancelled externally). /// Removes them from the ring so `isNavigating` returns false. diff --git a/tests/integration/.gitignore b/tests/integration/.gitignore new file mode 100644 index 0000000..d0c3a82 --- /dev/null +++ b/tests/integration/.gitignore @@ -0,0 +1 @@ +zig-pkg/ diff --git a/tests/integration/README.md b/tests/integration/README.md new file mode 100644 index 0000000..40c5aa7 --- /dev/null +++ b/tests/integration/README.md @@ -0,0 +1,26 @@ +# Serial endpoint preparation integration + +This harness uses `labelle-engine`'s Game with its headless ECS backend and the +actual pathfinder controller. It does not recreate Game, hierarchy, revision or +controller behavior in a test double. It is separate from the library's regular +unit/spec targets so using the published pathfinder does not gain an engine +runtime dependency. + +Required sibling layout: + +``` +labelle-core/ +labelle-engine/ # engine#916, b93dafd1295fe5324be70c6249fc4687a78b8fe3 +labelle-pathfinding/ +``` + +With Zig 0.16.0, from `labelle-pathfinding`: + +``` +zig build test --build-file tests/integration/build.zig --summary all +``` + +The ordinary `zig build test spec` targets remain required. CI runs both and pins +the engine commit supplying position revisions. Replace that pin with the released +API when engine#916 lands. This suite validates the controller/engine lifecycle; +it does not measure WASM performance or replace actual-game acceptance tests. diff --git a/tests/integration/build.zig b/tests/integration/build.zig new file mode 100644 index 0000000..6a21434 --- /dev/null +++ b/tests/integration/build.zig @@ -0,0 +1,17 @@ +const std = @import("std"); +pub fn build(b: *std.Build) void { + const target = b.standardTargetOptions(.{}); + const optimize = b.standardOptimizeOption(.{}); + const engine = b.dependency("engine", .{ .target = target, .optimize = optimize }).module("engine"); + const pathfinder = b.dependency("pathfinder", .{ .target = target, .optimize = optimize }).module("labelle_pathfinding"); + const core = engine.import_table.get("labelle-core").?; + pathfinder.addImport("labelle-core", core); + const tests = b.addTest(.{ .root_module = b.createModule(.{ + .root_source_file = b.path("preparation_test.zig"), + .target = target, + .optimize = optimize, + .link_libc = true, + .imports = &.{ .{ .name = "engine", .module = engine }, .{ .name = "pathfinder", .module = pathfinder }, .{ .name = "labelle-core", .module = core } }, + }) }); + b.step("test", "Run preparation with the real engine's headless ECS game").dependOn(&b.addRunArtifact(tests).step); +} diff --git a/tests/integration/build.zig.zon b/tests/integration/build.zig.zon new file mode 100644 index 0000000..2ee8717 --- /dev/null +++ b/tests/integration/build.zig.zon @@ -0,0 +1,11 @@ +.{ + .fingerprint = 0x2a94eae760223628, + .name = .prepared_endpoint_integration, + .version = "0.0.0", + .minimum_zig_version = "0.16.0", + .dependencies = .{ + .pathfinder = .{ .path = "../.." }, + .engine = .{ .path = "../../../labelle-engine" }, + }, + .paths = .{ "build.zig", "build.zig.zon", "preparation_test.zig" }, +} diff --git a/tests/integration/preparation_test.zig b/tests/integration/preparation_test.zig new file mode 100644 index 0000000..d2eaf3b --- /dev/null +++ b/tests/integration/preparation_test.zig @@ -0,0 +1,282 @@ +const std = @import("std"); +const testing = std.testing; +const engine = @import("engine"); +const pf = @import("pathfinder"); +const Game = engine.game_mod.Game; +const Entity = Game.EntityType; +const C = pf.Controller; +const CMN = pf.ClosestMovementNode; +const Participant = struct { value: u8 = 0 }; + +fn node(game: *Game, x: f32) Entity { + const e = game.createEntity(); + game.addComponent(e, pf.MovementNode{}); + game.setPosition(e, .{ .x = x, .y = 0 }); + return e; +} +fn item(game: *Game, x: f32) Entity { + const e = game.createEntity(); + game.addComponent(e, Participant{}); + game.setPosition(e, .{ .x = x, .y = 0 }); + return e; +} +fn stage(game: *Game, budget: usize) !pf.controller.PreparationStats { + var view = game.active_world.ecs_backend.view(.{Participant}, .{}); + defer view.deinit(); + return try C.prepareEndpointBatch(game, &view, budget); +} +fn run(game: *Game, budget: usize) !pf.controller.PreparationStats { + _ = try stage(game, budget); + return C.publishEndpointBatch(game); +} +fn setup(game: *Game) !void { + try C.setup(game); + C.enableEndpointPreparation(game); + C.advance(game, 0); +} + +test "CMNs publish only at the barrier; bounded batches rotate past newly stale participants" { + var game = Game.init(testing.allocator); + defer game.deinit(); + const n = node(&game, 0); + game.addComponent(n, Participant{}); // graph nodes must never get self-CMNs + const a = item(&game, 1); + const b = item(&game, 2); + const c = item(&game, 3); + try setup(&game); + defer C.deinit(&game); + const first = try stage(&game, 1); + try testing.expectEqual(@as(usize, 1), first.selected); + try testing.expect(!game.hasComponent(a, CMN)); + try testing.expect(!game.hasComponent(b, CMN)); + try testing.expectEqual(@as(usize, 1), C.publishEndpointBatch(&game).published); + try testing.expectEqual(@as(u32, 0), game.getComponent(a, CMN).?.node_id); + game.setPosition(a, .{ .x = 10 }); + _ = try run(&game, 1); + try testing.expect(game.hasComponent(b, CMN)); + _ = try run(&game, 1); + try testing.expect(game.hasComponent(c, CMN)); + try testing.expect(!game.hasComponent(n, CMN)); + _ = try run(&game, 1); + try testing.expectEqual(game.positionRevision(a).?, game.getComponent(a, CMN).?.stamp.position_revision); +} + +test "ancestor movement between compute and publication discards the old result" { + var game = Game.init(testing.allocator); + defer game.deinit(); + _ = node(&game, 0); + const parent = game.createEntity(); + const e = item(&game, 1); + game.setParent(e, parent, .{}); + try setup(&game); + defer C.deinit(&game); + _ = try stage(&game, 2); + try testing.expectError(error.PreparationInProgress, stage(&game, 2)); + game.setPosition(parent, .{ .x = 10 }); + const stale = C.publishEndpointBatch(&game); + try testing.expectEqual(@as(usize, 1), stale.discarded); + try testing.expect(!game.hasComponent(e, CMN)); + try testing.expectEqual(@as(usize, 1), (try run(&game, 2)).published); +} + +test "off-graph completion is skipped until the position changes" { + var game = Game.init(testing.allocator); + defer game.deinit(); + _ = node(&game, 0); + const e = item(&game, 10000); + try setup(&game); + defer C.deinit(&game); + try testing.expectEqual(@as(usize, 1), (try run(&game, 2)).off_graph); + try testing.expectEqual(CMN.Status.off_graph, game.getComponent(e, CMN).?.status); + try testing.expectEqual(@as(usize, 0), (try run(&game, 2)).selected); + game.setPosition(e, .{ .x = 2 }); + try testing.expectEqual(@as(usize, 1), (try run(&game, 2)).published); + try testing.expectEqual(CMN.Status.ready, game.getComponent(e, CMN).?.status); +} + +test "node movement and link changes reject staging and rebuild on advance with unchanged node count" { + var game = Game.init(testing.allocator); + defer game.deinit(); + const n = node(&game, 0); + _ = node(&game, 100); + const e = item(&game, 1); + try setup(&game); + defer C.deinit(&game); + _ = try stage(&game, 2); + game.setPosition(n, .{ .x = 10 }); + try testing.expect(!C.publishEndpointBatch(&game).graph_ready); + try testing.expect(!game.hasComponent(e, CMN)); + const epoch = C.graphEpoch(&game); + C.advance(&game, 0); + try testing.expect(C.graphEpoch(&game) > epoch); + try testing.expectEqual(@as(usize, 1), (try run(&game, 2)).published); + game.setComponent(n, pf.MovementLink{ .dx = 90 }); + try testing.expect(!(try stage(&game, 2)).graph_ready); + C.advance(&game, 0); + try testing.expect((try run(&game, 2)).graph_ready); +} + +test "destroyed endpoints never receive a staged result" { + var game = Game.init(testing.allocator); + defer game.deinit(); + _ = node(&game, 0); + const e = item(&game, 1); + try setup(&game); + defer C.deinit(&game); + _ = try stage(&game, 2); + game.destroyEntity(e); + const replacement = item(&game, 2); + try testing.expectEqual(@as(usize, 1), C.publishEndpointBatch(&game).discarded); + try testing.expect(!game.hasComponent(replacement, CMN)); + try testing.expectEqual(@as(usize, 1), (try run(&game, 2)).published); +} + +test "no graph and zero budget remain normal empty preparation outcomes" { + var game = Game.init(testing.allocator); + defer game.deinit(); + const e = item(&game, 1); + try setup(&game); + defer C.deinit(&game); + try testing.expect(!(try run(&game, 2)).graph_ready); + _ = node(&game, 0); + C.advance(&game, 0); + try testing.expectEqual(@as(usize, 0), (try run(&game, 0)).selected); + try testing.expect(!game.hasComponent(e, CMN)); + try testing.expectEqual(@as(usize, 1), (try run(&game, 2)).published); +} + +test "rebuild retains the old debugging record but legacy targetPosition resolves again" { + var game = Game.init(testing.allocator); + defer game.deinit(); + const a = node(&game, 0); + const b = node(&game, 70); + const e = item(&game, 1); + try setup(&game); + defer C.deinit(&game); + _ = try run(&game, 1); + const old = game.getComponent(e, CMN).?.*; + game.setPosition(a, .{ .x = 200 }); + C.advance(&game, 0); + const stale = game.getComponent(e, CMN).?.*; + try testing.expectEqual(CMN.Status.pending, stale.status); + try testing.expectEqual(old.node_entity, stale.node_entity); + try testing.expectEqualDeep(old.last_ready_stamp, stale.last_ready_stamp); + try testing.expectEqualDeep(game.getWorldPosition(b), C.targetPosition(&game, e)); +} + +test "graph replacement between compute and publication discards the old batch and preserves rotation" { + var game = Game.init(testing.allocator); + defer game.deinit(); + const n = node(&game, 0); + const a = item(&game, 1); + const b = item(&game, 2); + try setup(&game); + defer C.deinit(&game); + _ = try stage(&game, 1); + game.setPosition(n, .{ .x = 2 }); + C.advance(&game, 0); + try testing.expectEqual(@as(usize, 1), C.publishEndpointBatch(&game).discarded); + _ = try run(&game, 1); + try testing.expect(!game.hasComponent(a, CMN)); + try testing.expect(game.hasComponent(b, CMN)); +} + +test "batch allocation failure cannot publish CMNs or advance the cursor" { + 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, stage(&game, 1)); + try testing.expectEqual(@as(?u64, null), state.preparation_batch.cursor); + try testing.expectEqual(@as(usize, 0), C.publishEndpointBatch(&game).published); + try testing.expect(!game.hasComponent(e, CMN)); +} + +test "controller replacement starts a fresh world stamp with no staged results" { + var game = Game.init(testing.allocator); + defer game.deinit(); + _ = node(&game, 0); + const e = item(&game, 1); + try setup(&game); + defer C.deinit(&game); + _ = try run(&game, 1); + const old_world = game.getComponent(e, CMN).?.stamp.world_revision; + game.setPosition(e, .{ .x = 2 }); + _ = try stage(&game, 1); + try C.setup(&game); // replaces the state and destroys outstanding snapshots + C.enableEndpointPreparation(&game); + C.advance(&game, 0); + try testing.expectEqual(@as(usize, 0), C.publishEndpointBatch(&game).published); + _ = try run(&game, 1); + try testing.expect(game.getComponent(e, CMN).?.stamp.world_revision != old_world); +} + +test "snapshot allocation failure and explicit cancellation leave the turn unconsumed" { + var game = Game.init(testing.allocator); + defer game.deinit(); + _ = node(&game, 0); + const a = item(&game, 1); + const b = item(&game, 2); + try setup(&game); + defer C.deinit(&game); + const state = C.getState(&game).?; + try state.preparation_batch.begin(testing.allocator, 1); + 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, stage(&game, 1)); + } + try testing.expectEqual(@as(?u64, null), state.preparation_batch.cursor); + const computed = try stage(&game, 1); + try testing.expectEqual(@as(usize, 1), computed.nearest_searches); + C.cancelEndpointBatch(&game); + try testing.expectEqual(@as(usize, 0), C.publishEndpointBatch(&game).published); + try testing.expect(!game.hasComponent(a, CMN)); + _ = try run(&game, 1); + try testing.expect(game.hasComponent(a, CMN)); + try testing.expect(!game.hasComponent(b, CMN)); + try testing.expectEqual(@as(usize, 0), C.publishEndpointBatch(&game).published); +} + +test "node removal and stair edits invalidate an already computed batch" { + 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 stage(&game, 1); + game.addComponent(n, pf.MovementStair{}); + try testing.expectEqual(@as(usize, 1), C.publishEndpointBatch(&game).discarded); + C.advance(&game, 0); + _ = try stage(&game, 1); + C.removeNode(&game, game.getComponent(n, pf.MovementNode).?.node_id); + try testing.expectEqual(@as(usize, 1), C.publishEndpointBatch(&game).discarded); + try testing.expect(!game.hasComponent(e, CMN)); +} + +test "legacy targetPosition never treats off-graph debugging data as a ready node" { + var game = Game.init(testing.allocator); + defer game.deinit(); + _ = node(&game, 0); + const e = item(&game, 1); + try setup(&game); + defer C.deinit(&game); + _ = try run(&game, 1); + const old = game.getComponent(e, CMN).?.*; + game.setPosition(e, .{ .x = 10000 }); + _ = try run(&game, 1); + const off_graph = game.getComponent(e, CMN).?.*; + try testing.expectEqual(CMN.Status.off_graph, off_graph.status); + try testing.expectEqual(old.node_entity, off_graph.node_entity); + try testing.expectEqualDeep(game.getWorldPosition(e), C.targetPosition(&game, e)); +} 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; +}