diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 37d321b..4c8b2e7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -13,9 +13,9 @@ jobs: - uses: actions/checkout@v4 - name: Setup Zig - uses: mlugg/setup-zig@v1 + uses: mlugg/setup-zig@v2 with: - version: 0.14.0 + version: 0.15.2 - name: Run tests run: zig build test diff --git a/build.zig.zon b/build.zig.zon index ab72545..ec9538c 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -2,12 +2,12 @@ .name = .zig_utils, .version = "0.5.0", .fingerprint = 0x6dc482caf73c4a75, - .minimum_zig_version = "0.14.0", + .minimum_zig_version = "0.15.2", .dependencies = .{ .zspec = .{ .url = "https://github.com/apotema/zspec/archive/refs/heads/main.tar.gz", - .hash = "zspec-0.3.0-jaKLbbqZAgAB-MBfy6M3HKB3jSTQ-TsNW9OxpLhLlav5", + .hash = "zspec-0.6.0-jaKLbU5DAwAYa0jR0cnFGuIbSrxD6ojh-oyXj9ooyb3V", }, }, diff --git a/claude.md b/claude.md index 5a311c5..3430579 100644 --- a/claude.md +++ b/claude.md @@ -6,10 +6,18 @@ Standalone math utilities library for Zig. Part of the labelle-toolkit. ``` src/ - root.zig - Main module exports - vector.zig - Position and PositionI types - quad_tree.zig - QuadTree spatial partitioning (generic, Position-based) - sweep_and_prune.zig - Sweep and Prune collision detection + root.zig - Main module exports + vector.zig - Position and PositionI types + quad_tree.zig - QuadTree spatial partitioning + sweep_and_prune.zig - Sweep and Prune collision detection + sparse_set.zig - SparseSet O(1) key-value mapping + z_index_buckets.zig - ZIndexBuckets sorted storage by u8 key + zon_coercion.zig - Comptime ZON to struct conversion + hook_dispatcher.zig - Zero-overhead comptime event dispatcher + floyd_warshall.zig - Floyd-Warshall all-pairs shortest path + floyd_warshall_optimized.zig - SIMD/parallel Floyd-Warshall + a_star.zig - A* single-source pathfinding + heuristics.zig - Distance heuristics for A* tests/ root.zig - Test entry point vector_test.zig - Position/PositionI tests @@ -23,6 +31,12 @@ tests/ - `PositionI` (i32) - Integer position for pixel-perfect work - `QuadTree(T)` - Generic spatial index with Position-based queries - `SweepAndPrune(T)` - Generic broad-phase collision detection +- `SparseSet(T)` - O(1) key-value mapping with cache-friendly iteration +- `ZIndexBuckets(T)` - Bucket-sorted storage by u8 key (256 buckets) +- `HookDispatcher` - Zero-overhead comptime event dispatcher +- `FloydWarshall` - All-pairs shortest path O(V³) +- `FloydWarshallOptimized` - SIMD/parallel Floyd-Warshall (5-16x faster) +- `AStar` - A* single-source pathfinding with heuristics - `Rectangle` - AABB for bounds and collision - `EntityPoint(T)` - Point with generic ID for QuadTree - `AABB` - Axis-aligned bounding box for SweepAndPrune @@ -59,6 +73,65 @@ zig build test - Generic ID type support - Position-based AABB queries +### SparseSet +- O(1) insert, remove, lookup (worst-case, not amortized) +- Cache-friendly dense array iteration +- Generic value type support +- Fixed memory based on max key range +- Ideal for entity -> component mappings + +**Benchmark results (vs HashMap):** +| Operation | SparseSet | HashMap | +|-----------|-----------|---------| +| contains | 0.76 ns | 4.61 ns | + +**Trade-off:** ~40KB memory for 10k max keys vs variable for HashMap + +### ZIndexBuckets +- 256 buckets (one per u8 z-index level) +- O(1) insert, O(bucket_size) remove +- O(256 + n) ordered iteration +- Generic over item type T +- Optional custom equality via `eql` method + +### ZON Coercion +- Comptime conversion of anonymous structs to typed structs +- Handles nested structs, optionals, slices, arrays +- Tagged union coercion from enum literals or structs +- Struct merging with override semantics +- Functions: `coerceValue`, `buildStruct`, `tupleToSlice`, `mergeStructs` + +### HookDispatcher +- Zero-overhead comptime event dispatch +- Handlers resolved entirely at compile time +- No runtime overhead for missing handlers +- `MergeHooks` for composing multiple handler structs +- `EmptyDispatcher` for default no-op dispatching + +### Graph Algorithms + +**Floyd-Warshall** (all-pairs shortest path): +- O(V³) time, O(V²) space +- Best for dense graphs, frequent all-pairs queries +- Entity ID mapping support +- Optimized variant with SIMD (5-8x faster) and parallel (up to 16x) + +**A\*** (single-source shortest path): +- Heuristic-guided best-first search +- Built-in heuristics: Euclidean, Manhattan, Chebyshev, Octile, Zero +- Custom heuristic function support +- Entity ID mapping for ECS integration +- Best for sparse graphs, real-time queries + +**Heuristics**: +| Movement Type | Recommended | +|---------------|-------------| +| Any-angle | Euclidean | +| 4-directional | Manhattan | +| 8-dir equal cost | Chebyshev | +| 8-dir realistic | Octile | +| Unknown | Zero (Dijkstra) | + ## Related - labelle-pathfinding - Uses QuadTree for spatial queries diff --git a/src/a_star.zig b/src/a_star.zig new file mode 100644 index 0000000..e421ee4 --- /dev/null +++ b/src/a_star.zig @@ -0,0 +1,406 @@ +//! A* (A-Star) Pathfinding Algorithm +//! +//! A best-first search algorithm that finds the shortest path between a source +//! and destination node. Uses heuristics to guide the search, making it more +//! efficient than Dijkstra's algorithm for single-source pathfinding. +//! +//! ## Features +//! - Single-source shortest path (efficient for point-to-point queries) +//! - Multiple built-in heuristics (Euclidean, Manhattan, Chebyshev, Octile) +//! - Custom heuristic support +//! - Entity ID mapping for integration with external systems +//! - Adjacency list representation (memory efficient for sparse graphs) +//! +//! ## When to use A* vs Floyd-Warshall +//! - **A***: Best for single-source queries, large sparse graphs, real-time games +//! - **Floyd-Warshall**: Best when you need all-pairs paths, dense graphs, or +//! when paths are queried repeatedly between many node pairs + +const std = @import("std"); +const heuristics_mod = @import("heuristics.zig"); +const SparseSet = @import("sparse_set.zig").SparseSet; + +pub const Heuristic = heuristics_mod.Heuristic; +pub const HeuristicFn = heuristics_mod.HeuristicFn; +pub const Position = heuristics_mod.Position; + +/// A* pathfinding algorithm with configurable heuristics. +/// Generic over WeightType for memory efficiency. +/// Supports both direct vertex indices and entity ID mapping. +pub fn AStar(comptime WeightType: type) type { + comptime { + const info = @typeInfo(WeightType); + if (info != .int or info.int.signedness != .unsigned) { + @compileError("WeightType must be an unsigned integer type"); + } + } + + const INF = std.math.maxInt(WeightType); + + return struct { + const Self = @This(); + + const Edge = struct { + to: u32, + weight: WeightType, + }; + const EdgeList = std.ArrayListUnmanaged(Edge); + const AdjacencyList = std.ArrayListUnmanaged(EdgeList); + + /// Priority queue node for A* open set + const PQNode = struct { + vertex: u32, + f_score: f32, + + fn compare(_: void, a: PQNode, b: PQNode) std.math.Order { + return std.math.order(a.f_score, b.f_score); + } + }; + + allocator: std.mem.Allocator, + adjacency: AdjacencyList, + positions: SparseSet(u32, Position), + ids: SparseSet(u32, u32), + reverse_ids: SparseSet(u32, u32), + last_key: u32 = 0, + size: u32 = 100, + heuristic_type: Heuristic, + custom_heuristic: ?HeuristicFn, + + pub fn init(allocator: std.mem.Allocator) !Self { + var positions = try SparseSet(u32, Position).init(allocator, 1024, 64); + errdefer positions.deinit(); + + var ids = try SparseSet(u32, u32).init(allocator, 1024, 64); + errdefer ids.deinit(); + + const reverse_ids = try SparseSet(u32, u32).init(allocator, 1024, 64); + + return .{ + .allocator = allocator, + .adjacency = .empty, + .positions = positions, + .ids = ids, + .reverse_ids = reverse_ids, + .heuristic_type = .euclidean, + .custom_heuristic = null, + }; + } + + pub fn deinit(self: *Self) void { + for (self.adjacency.items) |*edges| { + edges.deinit(self.allocator); + } + self.adjacency.deinit(self.allocator); + self.positions.deinit(); + self.ids.deinit(); + self.reverse_ids.deinit(); + } + + /// Set the heuristic type to use for pathfinding + pub fn setHeuristic(self: *Self, heuristic_type: Heuristic) void { + self.heuristic_type = heuristic_type; + self.custom_heuristic = null; + } + + /// Set a custom heuristic function + pub fn setCustomHeuristic(self: *Self, heuristic_fn: HeuristicFn) void { + self.custom_heuristic = heuristic_fn; + } + + /// Set the position of a node (used for heuristic calculation) + pub fn setNodePosition(self: *Self, node: u32, pos: Position) !void { + try self.positions.put(node, pos); + } + + /// Set node position using entity ID mapping + pub fn setNodePositionWithMapping(self: *Self, entity: u32, pos: Position) !void { + const internal_id = try self.getOrCreateMapping(entity); + try self.positions.put(internal_id, pos); + } + + /// Generate a new internal key for entity mapping + fn newKey(self: *Self) u32 { + self.last_key += 1; + return self.last_key - 1; + } + + /// Get or create an internal ID mapping for an entity + fn getOrCreateMapping(self: *Self, entity: u32) !u32 { + if (self.ids.get(entity)) |id| { + return id; + } + const new_id = self.newKey(); + try self.ids.put(entity, new_id); + errdefer _ = self.ids.remove(entity); + try self.reverse_ids.put(new_id, entity); + return new_id; + } + + /// Resize the graph to support a given number of vertices + pub fn resize(self: *Self, size: u32) void { + self.size = size; + } + + /// Reset the graph and prepare for new data + pub fn clean(self: *Self) !void { + self.last_key = 0; + + for (self.adjacency.items) |*edges| { + edges.deinit(self.allocator); + } + self.adjacency.clearRetainingCapacity(); + self.positions.clear(); + self.ids.clear(); + self.reverse_ids.clear(); + + // Initialize adjacency lists for each vertex + try self.adjacency.ensureTotalCapacity(self.allocator, self.size); + for (0..self.size) |_| { + try self.adjacency.append(self.allocator, .empty); + } + } + + pub const AddEdgeError = error{ + InvalidVertex, + OutOfMemory, + }; + + /// Add an edge between two vertices with given weight (direct index) + /// Returns error.InvalidVertex if u or v is out of bounds. + pub fn addEdge(self: *Self, u: u32, v: u32, w: WeightType) AddEdgeError!void { + if (u >= self.adjacency.items.len or v >= self.adjacency.items.len) { + return error.InvalidVertex; + } + try self.adjacency.items[u].append(self.allocator, .{ .to = v, .weight = w }); + } + + /// Add an edge using entity ID mapping (auto-assigns internal indices) + pub fn addEdgeWithMapping(self: *Self, u: u32, v: u32, w: WeightType) !void { + const u_internal = try self.getOrCreateMapping(u); + const v_internal = try self.getOrCreateMapping(v); + try self.addEdge(u_internal, v_internal, w); + } + + /// Calculate heuristic between two internal vertex indices + fn calculateHeuristic(self: *Self, from: u32, to: u32) f32 { + const from_pos = self.positions.get(from) orelse Position{ .x = 0, .y = 0 }; + const to_pos = self.positions.get(to) orelse Position{ .x = 0, .y = 0 }; + + if (self.custom_heuristic) |custom| { + return custom(from_pos, to_pos); + } + return heuristics_mod.calculate(self.heuristic_type, from_pos, to_pos); + } + + /// Run A* algorithm to find shortest path from source to destination. + /// Returns the path cost, or null if no path exists. + /// The path is stored in the provided ArrayList. + pub fn findPath( + self: *Self, + source: u32, + dest: u32, + path: *std.array_list.Managed(u32), + ) !?WeightType { + const n = self.adjacency.items.len; + if (source >= n or dest >= n) { + return null; + } + + path.clearRetainingCapacity(); + + if (source == dest) { + try path.append(source); + return 0; + } + + // Flat arrays instead of HashMaps - O(1) access, cache-friendly + const g_score = try self.allocator.alloc(WeightType, n); + defer self.allocator.free(g_score); + @memset(g_score, INF); + + const came_from = try self.allocator.alloc(u32, n); + defer self.allocator.free(came_from); + @memset(came_from, std.math.maxInt(u32)); // maxInt = no parent + + // BitSet instead of HashMap for closed_set - ~32x smaller, faster + var closed_set = try std.DynamicBitSet.initEmpty(self.allocator, n); + defer closed_set.deinit(); + + var open_set = std.PriorityQueue(PQNode, void, PQNode.compare).init(self.allocator, {}); + defer open_set.deinit(); + + // Initialize source + g_score[source] = 0; + const h = self.calculateHeuristic(source, dest); + try open_set.add(.{ .vertex = source, .f_score = h }); + + while (open_set.removeOrNull()) |current| { + if (current.vertex == dest) { + // Reconstruct path + var node = dest; + while (true) { + try path.append(node); + const parent = came_from[node]; + if (parent == std.math.maxInt(u32)) { + break; + } + node = parent; + } + // Reverse to get source -> dest order + std.mem.reverse(u32, path.items); + return g_score[dest]; + } + + if (closed_set.isSet(current.vertex)) { + continue; + } + closed_set.set(current.vertex); + + const current_g = g_score[current.vertex]; + + // Explore neighbors + for (self.adjacency.items[current.vertex].items) |edge| { + if (closed_set.isSet(edge.to)) { + continue; + } + + const tentative_g = current_g +| edge.weight; // Saturating add + const neighbor_g = g_score[edge.to]; + + if (tentative_g < neighbor_g) { + came_from[edge.to] = current.vertex; + g_score[edge.to] = tentative_g; + + const f = @as(f32, @floatFromInt(tentative_g)) + self.calculateHeuristic(edge.to, dest); + try open_set.add(.{ .vertex = edge.to, .f_score = f }); + } + } + } + + return null; // No path found + } + + pub const MappingError = error{ + CorruptedMapping, + OutOfMemory, + }; + + /// Find path using entity ID mapping + /// Returns error.CorruptedMapping if internal nodes cannot be reverse-mapped. + pub fn findPathWithMapping( + self: *Self, + source_entity: u32, + dest_entity: u32, + path: *std.array_list.Managed(u32), + ) MappingError!?WeightType { + const source = self.ids.get(source_entity) orelse return null; + const dest = self.ids.get(dest_entity) orelse return null; + + var internal_path = std.array_list.Managed(u32).init(self.allocator); + defer internal_path.deinit(); + + const cost = self.findPath(source, dest, &internal_path) catch return error.OutOfMemory; + + if (cost != null) { + path.clearRetainingCapacity(); + for (internal_path.items) |internal_id| { + const entity = self.reverse_ids.get(internal_id) orelse return error.CorruptedMapping; + try path.append(entity); + } + } + + return cost; + } + + /// Check if a path exists between two vertices (direct index) + pub fn hasPath(self: *Self, u: usize, v: usize) bool { + var path = std.array_list.Managed(u32).init(self.allocator); + defer path.deinit(); + + const result = self.findPath(@intCast(u), @intCast(v), &path) catch return false; + return result != null; + } + + /// Check if a path exists between two entities (using ID mapping) + pub fn hasPathWithMapping(self: *Self, u: u32, v: u32) bool { + var path = std.array_list.Managed(u32).init(self.allocator); + defer path.deinit(); + + const result = self.findPathWithMapping(u, v, &path) catch return false; + return result != null; + } + + /// Get the distance between two vertices (direct index) + /// Note: This runs A* each time - cache results if needed frequently + pub fn value(self: *Self, u: usize, v: usize) WeightType { + var path = std.array_list.Managed(u32).init(self.allocator); + defer path.deinit(); + + const result = self.findPath(@intCast(u), @intCast(v), &path) catch return INF; + return result orelse INF; + } + + /// Get the distance between two entities (using ID mapping) + pub fn valueWithMapping(self: *Self, u: u32, v: u32) WeightType { + var path = std.array_list.Managed(u32).init(self.allocator); + defer path.deinit(); + + const result = self.findPathWithMapping(u, v, &path) catch return INF; + return result orelse INF; + } + + pub const PathError = error{ + PathNotFound, + CorruptedMapping, + OutOfMemory, + }; + + /// Build the path from u to v and store in the provided ArrayList + /// Returns error.PathNotFound if no path exists between the nodes. + pub fn setPathWithMapping(self: *Self, path_list: *std.array_list.Managed(u32), u: u32, v: u32) PathError!void { + var path = std.array_list.Managed(u32).init(self.allocator); + defer path.deinit(); + + const result = try self.findPathWithMapping(u, v, &path); + if (result == null) { + return error.PathNotFound; + } + + path_list.clearRetainingCapacity(); + for (path.items) |node| { + try path_list.append(node); + } + } + + /// Get the next entity in the shortest path from u to v (using ID mapping) + pub fn nextWithMapping(self: *Self, u: u32, v: u32) u32 { + var path = std.array_list.Managed(u32).init(self.allocator); + defer path.deinit(); + + const result = self.findPathWithMapping(u, v, &path) catch return std.math.maxInt(u32); + if (result == null or path.items.len < 2) { + return std.math.maxInt(u32); + } + return path.items[1]; // Second element is next step + } + + /// Get the next vertex in the shortest path from u to v (direct index) + pub fn next(self: *Self, u: usize, v: usize) u32 { + var path = std.array_list.Managed(u32).init(self.allocator); + defer path.deinit(); + + const result = self.findPath(@intCast(u), @intCast(v), &path) catch return std.math.maxInt(u32); + if (result == null or path.items.len < 2) { + return std.math.maxInt(u32); + } + return path.items[1]; + } + + /// No-op for A* (paths computed on-demand) + pub fn generate(self: *Self) void { + _ = self; + // A* computes paths on-demand, no pre-computation needed + } + }; +} diff --git a/src/floyd_warshall.zig b/src/floyd_warshall.zig new file mode 100644 index 0000000..765f844 --- /dev/null +++ b/src/floyd_warshall.zig @@ -0,0 +1,239 @@ +//! Floyd-Warshall Algorithm Implementation +//! +//! Computes shortest paths between all pairs of vertices in a weighted graph. +//! Uses dynamic programming to find optimal paths and supports entity ID mapping. +//! +//! Complexity: O(V³) time, O(V²) space +//! +//! Best for: +//! - Dense graphs with many all-pairs queries +//! - Graphs that change infrequently +//! - Pre-computing all shortest paths + +const std = @import("std"); + +/// Floyd-Warshall all-pairs shortest path algorithm. +/// Generic over DistanceType for memory efficiency. +/// Supports both direct vertex indices and entity ID mapping. +pub fn FloydWarshall(comptime DistanceType: type) type { + comptime { + const info = @typeInfo(DistanceType); + if (info != .int or info.int.signedness != .unsigned) { + @compileError("DistanceType must be an unsigned integer type"); + } + } + + const INF = std.math.maxInt(DistanceType); + + return struct { + const Self = @This(); + const RowList = std.array_list.Managed(DistanceType); + const GraphList = std.array_list.Managed(RowList); + + size: u32 = 100, + graph: GraphList, + path: GraphList, + ids: std.AutoHashMap(u32, u32), + /// Reverse mapping: internal index to entity ID for O(1) reverse lookups + reverse_ids: std.AutoHashMap(u32, u32), + last_key: u32 = 0, + allocator: std.mem.Allocator, + + pub fn init(allocator: std.mem.Allocator) Self { + return .{ + .graph = GraphList.init(allocator), + .path = GraphList.init(allocator), + .ids = std.AutoHashMap(u32, u32).init(allocator), + .reverse_ids = std.AutoHashMap(u32, u32).init(allocator), + .allocator = allocator, + }; + } + + pub fn deinit(self: *Self) void { + for (self.graph.items) |*row| { + row.deinit(); + } + for (self.path.items) |*row| { + row.deinit(); + } + self.graph.deinit(); + self.path.deinit(); + self.ids.deinit(); + self.reverse_ids.deinit(); + } + + /// Generate a new internal key for entity mapping + pub fn newKey(self: *Self) u32 { + self.last_key += 1; + return self.last_key - 1; + } + + /// Add an edge between two vertices with given weight (direct index) + pub fn addEdge(self: *Self, u: u32, v: u32, w: DistanceType) void { + self.graph.items[u].items[v] = w; + } + + /// Get the distance between two vertices (direct index) + pub fn value(self: *Self, u: usize, v: usize) DistanceType { + return self.graph.items[u].items[v]; + } + + /// Check if a path exists between two vertices (direct index) + pub fn hasPath(self: *Self, u: usize, v: usize) bool { + return self.graph.items[u].items[v] != INF; + } + + /// Get the next vertex in the shortest path from u to v (direct index) + pub fn next(self: *Self, u: usize, v: usize) u32 { + return @intCast(self.path.items[u].items[v]); + } + + /// Resize the graph to support a given number of vertices + pub fn resize(self: *Self, size: u32) void { + self.size = size; + } + + /// Add an edge using entity ID mapping (auto-assigns internal indices) + /// Returns error.OutOfMemory if the internal hash map fails to allocate + pub fn addEdgeWithMapping(self: *Self, u: u32, v: u32, w: DistanceType) !void { + if (!self.ids.contains(u)) { + const key = self.newKey(); + try self.ids.put(u, key); + errdefer _ = self.ids.remove(u); + try self.reverse_ids.put(key, u); + } + if (!self.ids.contains(v)) { + const key = self.newKey(); + try self.ids.put(v, key); + errdefer _ = self.ids.remove(v); + try self.reverse_ids.put(key, v); + } + self.addEdge(self.ids.get(u).?, self.ids.get(v).?, w); + } + + /// Get the distance between two entities (using ID mapping) + pub fn valueWithMapping(self: *Self, u: u32, v: u32) DistanceType { + return self.value(self.ids.get(u).?, self.ids.get(v).?); + } + + /// Build the path from u to v and store in the provided ArrayList + /// Returns error.PathNotFound if no path exists between the nodes. + pub fn setPathWithMapping(self: *Self, path_list: *std.array_list.Managed(u32), u_node: u32, v_node: u32) !void { + const initial_len = path_list.items.len; + var current = u_node; + while (current != v_node) { + try path_list.append(current); + current = self.nextWithMapping(current, v_node); + if (current == std.math.maxInt(u32)) { + // Clear partial path data before returning error + path_list.shrinkRetainingCapacity(initial_len); + return error.PathNotFound; + } + } + try path_list.append(v_node); + } + + /// Build the path from u to v and store in the provided unmanaged ArrayList + /// Returns error.PathNotFound if no path exists between the nodes. + pub fn setPathWithMappingUnmanaged(self: *Self, allocator: std.mem.Allocator, path_list: *std.ArrayListUnmanaged(u32), u_node: u32, v_node: u32) !void { + const initial_len = path_list.items.len; + var current = u_node; + while (current != v_node) { + try path_list.append(allocator, current); + current = self.nextWithMapping(current, v_node); + if (current == std.math.maxInt(u32)) { + // Clear partial path data before returning error + path_list.shrinkRetainingCapacity(initial_len); + return error.PathNotFound; + } + } + try path_list.append(allocator, v_node); + } + + /// Get the next entity in the shortest path from u to v (using ID mapping) + /// Uses O(1) reverse lookup via reverse_ids map. + pub fn nextWithMapping(self: *Self, u: u32, v: u32) u32 { + const u_idx = self.ids.get(u) orelse return std.math.maxInt(u32); + const v_idx = self.ids.get(v) orelse return std.math.maxInt(u32); + const next_idx = self.next(u_idx, v_idx); + return self.reverse_ids.get(next_idx) orelse std.math.maxInt(u32); + } + + /// Check if a path exists between two entities (using ID mapping) + pub fn hasPathWithMapping(self: *Self, u: u32, v: u32) bool { + if (self.ids.get(u) == null or self.ids.get(v) == null) { + return false; + } + return self.hasPath(self.ids.get(u).?, self.ids.get(v).?); + } + + /// Reset the graph and prepare for new data + pub fn clean(self: *Self) !void { + self.last_key = 0; + for (self.graph.items) |*row| { + row.deinit(); + } + for (self.path.items) |*row| { + row.deinit(); + } + self.graph.clearRetainingCapacity(); + self.path.clearRetainingCapacity(); + self.ids.clearRetainingCapacity(); + self.reverse_ids.clearRetainingCapacity(); + + // Initialize adjacency matrix and path matrix + for (0..self.size) |_| { + var list = RowList.init(self.allocator); + var list_appended = false; + errdefer if (!list_appended) list.deinit(); + + var row_path = RowList.init(self.allocator); + var row_path_appended = false; + errdefer if (!row_path_appended) row_path.deinit(); + + for (0..self.size) |_| { + try list.append(0); + try row_path.append(0); + } + try self.graph.append(list); + list_appended = true; + + try self.path.append(row_path); + row_path_appended = true; + } + + // Set initial values: 0 for self-loops, INF for no edge + for (0..self.size) |i| { + for (0..self.size) |j| { + self.path.items[i].items[j] = @intCast(j); + if (i == j) { + self.graph.items[i].items[j] = 0; + } else { + self.graph.items[i].items[j] = INF; + } + } + } + } + + /// Run the Floyd-Warshall algorithm to compute all shortest paths + pub fn generate(self: *Self) void { + for (0..self.size) |k| { + for (0..self.size) |i| { + const dist_ik = self.graph.items[i].items[k]; + if (dist_ik == INF) continue; // Skip if no path to k + + for (0..self.size) |j| { + const dist_kj = self.graph.items[k].items[j]; + if (dist_kj == INF) continue; // Skip if no path from k + + const new_dist = dist_ik +| dist_kj; // Saturating add + if (new_dist < self.graph.items[i].items[j]) { + self.graph.items[i].items[j] = new_dist; + self.path.items[i].items[j] = self.path.items[i].items[k]; + } + } + } + } + } + }; +} diff --git a/src/floyd_warshall_optimized.zig b/src/floyd_warshall_optimized.zig new file mode 100644 index 0000000..e843901 --- /dev/null +++ b/src/floyd_warshall_optimized.zig @@ -0,0 +1,547 @@ +//! Optimized Floyd-Warshall Algorithm Implementation +//! +//! High-performance implementation with: +//! - Flat memory layout for cache efficiency +//! - SIMD vectorization for inner loop operations +//! - Multi-threaded parallelization across rows +//! +//! For graphs that change infrequently but require many arbitrary +//! source-destination queries. + +const std = @import("std"); + +const INF: u32 = std.math.maxInt(u32); + +/// Configuration for the optimized Floyd-Warshall algorithm +pub const Config = struct { + /// Enable multi-threaded parallelization (recommended for graphs with 256+ nodes) + parallel: bool = true, + /// Enable SIMD vectorization + simd: bool = true, + /// SIMD vector width (elements per vector operation) + /// - 4: 128-bit vectors (SSE, NEON) - widely supported + /// - 8: 256-bit vectors (AVX2) + /// - 16: 512-bit vectors (AVX-512) + vector_width: comptime_int = 4, +}; + +/// Optimized Floyd-Warshall all-pairs shortest path algorithm. +/// Uses flat memory layout, SIMD, and multi-threading for performance. +pub fn FloydWarshallOptimized(comptime config: Config) type { + return struct { + const Self = @This(); + + // SIMD vector configuration + const VectorWidth = config.vector_width; + const DistVector = @Vector(VectorWidth, u32); + const IndexVector = @Vector(VectorWidth, u32); + + size: u32 = 0, + capacity: u32 = 0, + /// Flat distance matrix (size x size), row-major order + dist: []u32, + /// Flat next-hop matrix (size x size), row-major order + next: []u32, + /// Entity ID to internal index mapping + ids: std.AutoHashMap(u32, u32), + /// Reverse mapping: internal index to entity ID. + /// Provides O(1) reverse lookups, significantly faster than the legacy + /// implementation which required O(n) iteration for each reverse lookup. + reverse_ids: std.AutoHashMap(u32, u32), + last_key: u32 = 0, + allocator: std.mem.Allocator, + + pub fn init(allocator: std.mem.Allocator) Self { + return .{ + .dist = &.{}, + .next = &.{}, + .ids = std.AutoHashMap(u32, u32).init(allocator), + .reverse_ids = std.AutoHashMap(u32, u32).init(allocator), + .allocator = allocator, + }; + } + + pub fn deinit(self: *Self) void { + if (self.dist.len > 0) { + self.allocator.free(self.dist); + } + if (self.next.len > 0) { + self.allocator.free(self.next); + } + self.ids.deinit(); + self.reverse_ids.deinit(); + } + + /// Generate a new internal key for entity mapping + pub fn newKey(self: *Self) u32 { + self.last_key += 1; + return self.last_key - 1; + } + + /// Get flat array index for (row, col) + inline fn index(self: *const Self, row: u32, col: u32) usize { + return @as(usize, row) * @as(usize, self.size) + @as(usize, col); + } + + /// Add an edge between two vertices with given weight (direct index) + pub fn addEdge(self: *Self, u: u32, v: u32, w: u32) void { + self.dist[self.index(u, v)] = w; + } + + /// Get the distance between two vertices (direct index) + pub fn value(self: *const Self, u: u32, v: u32) u32 { + return self.dist[self.index(u, v)]; + } + + /// Check if a path exists between two vertices (direct index) + pub fn hasPath(self: *const Self, u: u32, v: u32) bool { + return self.dist[self.index(u, v)] != INF; + } + + /// Get the next vertex in the shortest path from u to v (direct index) + pub fn getNext(self: *const Self, u: u32, v: u32) u32 { + return self.next[self.index(u, v)]; + } + + /// Resize the graph to support a given number of vertices + pub fn resize(self: *Self, new_size: u32) void { + self.size = new_size; + } + + /// Add an edge using entity ID mapping (auto-assigns internal indices) + /// Returns error.OutOfMemory if the internal hash maps fail to allocate + pub fn addEdgeWithMapping(self: *Self, u: u32, v: u32, w: u32) !void { + if (!self.ids.contains(u)) { + const key = self.newKey(); + try self.ids.put(u, key); + try self.reverse_ids.put(key, u); + } + if (!self.ids.contains(v)) { + const key = self.newKey(); + try self.ids.put(v, key); + try self.reverse_ids.put(key, v); + } + self.addEdge(self.ids.get(u).?, self.ids.get(v).?, w); + } + + /// Get the distance between two entities (using ID mapping) + pub fn valueWithMapping(self: *const Self, u: u32, v: u32) u32 { + const u_idx = self.ids.get(u) orelse return INF; + const v_idx = self.ids.get(v) orelse return INF; + return self.value(u_idx, v_idx); + } + + /// Get the next entity in the shortest path from u to v (using ID mapping) + /// Returns INF if no path exists + pub fn nextWithMapping(self: *const Self, u: u32, v: u32) u32 { + const u_idx = self.ids.get(u) orelse return INF; + const v_idx = self.ids.get(v) orelse return INF; + const next_idx = self.getNext(u_idx, v_idx); + return self.reverse_ids.get(next_idx) orelse INF; + } + + /// Check if a path exists between two entities (using ID mapping) + pub fn hasPathWithMapping(self: *const Self, u: u32, v: u32) bool { + const u_idx = self.ids.get(u) orelse return false; + const v_idx = self.ids.get(v) orelse return false; + return self.hasPath(u_idx, v_idx); + } + + pub const PathError = error{ + NoPathFound, + OutOfMemory, + }; + + /// Build the path from u to v and store in the provided ArrayList + /// Returns error.NoPathFound if no path exists between the nodes + pub fn setPathWithMapping(self: *const Self, path_list: *std.array_list.Managed(u32), u_node: u32, v_node: u32) PathError!void { + var current = u_node; + while (current != v_node) { + try path_list.append(current); + current = self.nextWithMapping(current, v_node); + if (current == INF) { + return error.NoPathFound; + } + } + try path_list.append(v_node); + } + + /// Build the path from u to v and store in the provided unmanaged ArrayList + /// Returns error.NoPathFound if no path exists between the nodes + pub fn setPathWithMappingUnmanaged(self: *const Self, allocator: std.mem.Allocator, path_list: *std.ArrayListUnmanaged(u32), u_node: u32, v_node: u32) PathError!void { + var current = u_node; + while (current != v_node) { + try path_list.append(allocator, current); + current = self.nextWithMapping(current, v_node); + if (current == INF) { + return error.NoPathFound; + } + } + try path_list.append(allocator, v_node); + } + + pub const CleanError = error{ + SizeOverflow, + OutOfMemory, + }; + + /// Reset the graph and prepare for new data + pub fn clean(self: *Self) CleanError!void { + self.last_key = 0; + self.ids.clearRetainingCapacity(); + self.reverse_ids.clearRetainingCapacity(); + + // Check for overflow before computing matrix_size + const n: usize = self.size; + const matrix_size = std.math.mul(usize, n, n) catch return error.SizeOverflow; + + // Reallocate if needed + if (self.capacity < self.size) { + // Allocate both new arrays first (before freeing old ones) + // This ensures no state change if allocation fails + const new_dist = try self.allocator.alloc(u32, matrix_size); + errdefer self.allocator.free(new_dist); + + const new_next = try self.allocator.alloc(u32, matrix_size); + + // Now safe to free old arrays and update state + if (self.dist.len > 0) { + self.allocator.free(self.dist); + } + if (self.next.len > 0) { + self.allocator.free(self.next); + } + self.dist = new_dist; + self.next = new_next; + self.capacity = self.size; + } + + // Initialize matrices + const dist_slice = self.dist[0..matrix_size]; + const next_slice = self.next[0..matrix_size]; + + // Set all distances to INF + @memset(dist_slice, INF); + + // Initialize next-hop and diagonal + for (0..self.size) |i| { + for (0..self.size) |j| { + const idx = i * self.size + j; + next_slice[idx] = @intCast(j); + } + // Self-loops have distance 0 + dist_slice[i * self.size + i] = 0; + } + } + + /// Run the Floyd-Warshall algorithm to compute all shortest paths + pub fn generate(self: *Self) void { + if (config.parallel and self.size > 64) { + self.generateParallel(); + } else if (config.simd) { + self.generateSimd(); + } else { + self.generateScalar(); + } + } + + /// Scalar implementation (baseline) + fn generateScalar(self: *Self) void { + const n = self.size; + for (0..n) |k| { + for (0..n) |i| { + const dist_ik = self.dist[i * n + k]; + if (dist_ik == INF) continue; // Optimization: skip if no path to k + + for (0..n) |j| { + const dist_kj = self.dist[k * n + j]; + if (dist_kj == INF) continue; + + const new_dist = dist_ik +| dist_kj; // Saturating add to prevent overflow + const idx = i * n + j; + if (new_dist < self.dist[idx]) { + self.dist[idx] = new_dist; + self.next[idx] = self.next[i * n + k]; + } + } + } + } + } + + /// SIMD-optimized implementation + fn generateSimd(self: *Self) void { + const n = self.size; + const n_usize: usize = n; + + for (0..n) |k| { + for (0..n) |i| { + const dist_ik = self.dist[i * n_usize + k]; + if (dist_ik == INF) continue; + + const next_ik = self.next[i * n_usize + k]; + const dist_ik_vec: DistVector = @splat(dist_ik); + const next_ik_vec: IndexVector = @splat(next_ik); + + const row_i_start = i * n_usize; + const row_k_start = k * n_usize; + + // Process in SIMD chunks + var j: usize = 0; + while (j + VectorWidth <= n_usize) : (j += VectorWidth) { + // Load dist[k][j..j+VectorWidth] + const dist_kj_vec: DistVector = self.dist[row_k_start + j ..][0..VectorWidth].*; + + // Load current dist[i][j..j+VectorWidth] + const dist_ij_ptr = self.dist[row_i_start + j ..][0..VectorWidth]; + const dist_ij_vec: DistVector = dist_ij_ptr.*; + + // Load current next[i][j..j+VectorWidth] + const next_ij_ptr = self.next[row_i_start + j ..][0..VectorWidth]; + const next_ij_vec: IndexVector = next_ij_ptr.*; + + // Calculate new distances (saturating add) + const new_dist_vec = dist_ik_vec +| dist_kj_vec; + + // Compare: new_dist < dist_ij + const mask = new_dist_vec < dist_ij_vec; + + // Select: if mask then new_dist else dist_ij + dist_ij_ptr.* = @select(u32, mask, new_dist_vec, dist_ij_vec); + next_ij_ptr.* = @select(u32, mask, next_ik_vec, next_ij_vec); + } + + // Handle remaining elements + while (j < n_usize) : (j += 1) { + const dist_kj = self.dist[row_k_start + j]; + if (dist_kj == INF) continue; + + const new_dist = dist_ik +| dist_kj; + const idx = row_i_start + j; + if (new_dist < self.dist[idx]) { + self.dist[idx] = new_dist; + self.next[idx] = next_ik; + } + } + } + } + } + + /// Multi-threaded parallel implementation using row decomposition + /// Based on the semaphore-per-row synchronization pattern + fn generateParallel(self: *Self) void { + const n = self.size; + if (n == 0) return; + + // Determine thread count + const cpu_count = std.Thread.getCpuCount() catch 4; + const thread_count: usize = @min(cpu_count, n); + + // Fall back to SIMD for small graphs or single core + if (thread_count <= 1 or n < 32) { + self.generateSimd(); + return; + } + + // Allocate synchronization counters (one per k value + 1) + // Each counter tracks how many threads have signaled that row k is ready + const sync_counters = self.allocator.alloc(std.atomic.Value(u32), n + 1) catch { + self.generateSimd(); + return; + }; + defer self.allocator.free(sync_counters); + + // Initialize: first counter allows all threads to start, rest are 0 + sync_counters[0] = std.atomic.Value(u32).init(@intCast(thread_count)); + for (1..n + 1) |i| { + sync_counters[i] = std.atomic.Value(u32).init(0); + } + + // Calculate row distribution + const rows_per_thread = n / thread_count; + const extra_rows = n % thread_count; + + // Spawn worker threads - collect row assignments first + const threads = self.allocator.alloc(std.Thread, thread_count - 1) catch { + self.generateSimd(); + return; + }; + defer self.allocator.free(threads); + + const row_ranges = self.allocator.alloc([2]usize, thread_count) catch { + self.generateSimd(); + return; + }; + defer self.allocator.free(row_ranges); + + // Calculate all row assignments upfront + var next_row: usize = 0; + for (0..thread_count) |t| { + const start = next_row; + var end = start + rows_per_thread; + if (t < extra_rows) end += 1; + row_ranges[t] = .{ start, end }; + next_row = end; + } + + // Try to spawn all worker threads + var spawned_count: usize = 0; + var spawn_failed = false; + + for (0..thread_count - 1) |t| { + threads[t] = std.Thread.spawn(.{}, parallelWorker, .{ + self, + row_ranges[t][0], + row_ranges[t][1], + thread_count, + sync_counters, + }) catch { + spawn_failed = true; + break; + }; + spawned_count += 1; + } + + if (spawn_failed) { + // Some threads spawned but not all - we can't use the parallel algorithm + // because it requires exactly thread_count participants for synchronization. + // Signal all sync counters to unblock spawned threads so they complete quickly. + for (0..n + 1) |i| { + _ = sync_counters[i].fetchAdd(@intCast(thread_count), .release); + } + // Join all spawned threads + for (threads[0..spawned_count]) |t| { + t.join(); + } + // Redo with SIMD (threads did partial work but it's incomplete) + self.generateSimd(); + return; + } + + // Main thread processes its portion (last entry in row_ranges) + self.parallelWorkerImpl(row_ranges[thread_count - 1][0], row_ranges[thread_count - 1][1], thread_count, sync_counters); + + // Join all spawned threads + for (threads[0..spawned_count]) |t| { + t.join(); + } + } + + /// Worker function for parallel threads + fn parallelWorker( + self: *Self, + start_row: usize, + end_row: usize, + thread_count: usize, + sync_counters: []std.atomic.Value(u32), + ) void { + self.parallelWorkerImpl(start_row, end_row, thread_count, sync_counters); + } + + /// Implementation of parallel worker logic + fn parallelWorkerImpl( + self: *Self, + start_row: usize, + end_row: usize, + thread_count: usize, + sync_counters: []std.atomic.Value(u32), + ) void { + const n = self.size; + const thread_count_u32: u32 = @intCast(thread_count); + + for (0..n) |k| { + // Wait until row k is ready (counter reaches thread_count) + // Use a spin loop with exponential backoff + var spins: u32 = 0; + while (sync_counters[k].load(.acquire) < thread_count_u32) { + spins += 1; + if (spins < 100) { + std.atomic.spinLoopHint(); + } else { + // Yield to OS scheduler after spinning + std.Thread.yield() catch {}; + spins = 0; + } + } + + // Process our assigned rows for this k iteration (with SIMD) + // Note: Reading row k during iteration k is safe because dist[k][j] and + // dist[i][k] don't change during iteration k (Floyd-Warshall property: + // dist[k][k] = 0, so min(dist[k][j], dist[k][k] + dist[k][j]) = dist[k][j]) + for (start_row..end_row) |i| { + self.processRowSimd(k, i); + } + + // Signal that iteration k is complete for our rows. + // Only one thread owns each row k (ranges are non-overlapping). + // That thread signals by adding thread_count to unblock all waiting threads. + if (k >= start_row and k < end_row) { + _ = sync_counters[k + 1].fetchAdd(thread_count_u32, .release); + } + } + } + + /// Process a single row with SIMD (for parallel use) + fn processRowSimd(self: *Self, k: usize, i: usize) void { + const n = self.size; + const n_usize: usize = n; + + const dist_ik = self.dist[i * n_usize + k]; + if (dist_ik == INF) return; + + const next_ik = self.next[i * n_usize + k]; + const dist_ik_vec: DistVector = @splat(dist_ik); + const next_ik_vec: IndexVector = @splat(next_ik); + + const row_i_start = i * n_usize; + const row_k_start = k * n_usize; + + // SIMD processing + var j: usize = 0; + while (j + VectorWidth <= n_usize) : (j += VectorWidth) { + const dist_kj_vec: DistVector = self.dist[row_k_start + j ..][0..VectorWidth].*; + const dist_ij_ptr = self.dist[row_i_start + j ..][0..VectorWidth]; + const dist_ij_vec: DistVector = dist_ij_ptr.*; + const next_ij_ptr = self.next[row_i_start + j ..][0..VectorWidth]; + const next_ij_vec: IndexVector = next_ij_ptr.*; + + const new_dist_vec = dist_ik_vec +| dist_kj_vec; + const mask = new_dist_vec < dist_ij_vec; + + dist_ij_ptr.* = @select(u32, mask, new_dist_vec, dist_ij_vec); + next_ij_ptr.* = @select(u32, mask, next_ik_vec, next_ij_vec); + } + + // Handle remaining elements + while (j < n_usize) : (j += 1) { + const dist_kj = self.dist[row_k_start + j]; + if (dist_kj == INF) continue; + + const new_dist = dist_ik +| dist_kj; + const idx = row_i_start + j; + if (new_dist < self.dist[idx]) { + self.dist[idx] = new_dist; + self.next[idx] = next_ik; + } + } + } + }; +} + +/// Parallel + SIMD optimized Floyd-Warshall (best for large graphs 256+ nodes) +/// Uses multi-threading with row decomposition and SIMD vectorization within each thread. +pub const FloydWarshallParallel = FloydWarshallOptimized(.{ + .parallel = true, + .simd = true, +}); + +/// SIMD-only version (no threading overhead for smaller graphs) +pub const FloydWarshallSimd = FloydWarshallOptimized(.{ + .parallel = false, + .simd = true, +}); + +/// Scalar version (for comparison/debugging) +pub const FloydWarshallScalar = FloydWarshallOptimized(.{ + .parallel = false, + .simd = false, +}); diff --git a/src/heuristics.zig b/src/heuristics.zig new file mode 100644 index 0000000..808d5c2 --- /dev/null +++ b/src/heuristics.zig @@ -0,0 +1,103 @@ +//! Heuristic Functions for Pathfinding +//! +//! Provides multiple distance heuristics for A* algorithm optimization. +//! All heuristics use Position (Vector2) for coordinates. +//! +//! ## Available Heuristics +//! - **Euclidean**: Straight-line distance, best for any-angle movement +//! - **Manhattan**: Grid distance, best for 4-directional movement +//! - **Chebyshev**: Chessboard distance, best for 8-dir with equal diagonal cost +//! - **Octile**: Optimal 8-directional with sqrt(2) diagonal cost +//! - **Zero**: No heuristic (Dijkstra's algorithm) +//! +//! ## Heuristic Selection Guide +//! | Movement Type | Recommended Heuristic | +//! |---------------|----------------------| +//! | Free/any-angle | Euclidean | +//! | 4-directional grid | Manhattan | +//! | 8-dir, equal diagonal cost | Chebyshev | +//! | 8-dir, realistic diagonal | Octile | +//! | Unknown/mixed | Zero (safest) | + +const std = @import("std"); +const vector = @import("vector.zig"); + +pub const Position = vector.Position; + +/// Built-in heuristic types for A* pathfinding +pub const Heuristic = enum { + /// Straight-line distance: sqrt((x2-x1)^2 + (y2-y1)^2) + /// Best for: Any-angle movement, open spaces + /// Admissible: Always + euclidean, + + /// Grid distance: |x2-x1| + |y2-y1| + /// Best for: 4-directional grid movement + /// Admissible: For 4-directional movement only + manhattan, + + /// Chessboard distance: max(|x2-x1|, |y2-y1|) + /// Best for: 8-directional movement with equal diagonal cost + /// Admissible: For 8-directional with uniform cost + chebyshev, + + /// Optimal 8-directional: max(dx,dy) + (sqrt(2)-1) * min(dx,dy) + /// Best for: 8-directional movement where diagonal costs sqrt(2) + /// Admissible: For 8-directional with sqrt(2) diagonal cost + octile, + + /// No heuristic (always returns 0) + /// Effect: Degrades A* to Dijkstra's algorithm + /// Use when: You need guaranteed shortest path without heuristic assumptions + zero, +}; + +/// Custom heuristic function type for user-defined heuristics. +/// Must return an estimated cost from position `a` to position `b`. +/// For admissibility, the estimate must never exceed the actual cost. +pub const HeuristicFn = *const fn (a: Position, b: Position) f32; + +/// sqrt(2) - 1, precomputed for octile heuristic +pub const SQRT2_MINUS_1: f32 = std.math.sqrt2 - 1.0; + +/// Calculate heuristic distance between two positions using the specified heuristic type. +pub fn calculate(heuristic: Heuristic, a: Position, b: Position) f32 { + return switch (heuristic) { + .euclidean => euclidean(a, b), + .manhattan => manhattan(a, b), + .chebyshev => chebyshev(a, b), + .octile => octile(a, b), + .zero => 0, + }; +} + +/// Euclidean (straight-line) distance +pub fn euclidean(a: Position, b: Position) f32 { + const dx = b.x - a.x; + const dy = b.y - a.y; + return @sqrt(dx * dx + dy * dy); +} + +/// Squared Euclidean distance (faster, avoids sqrt) +pub fn euclideanSquared(a: Position, b: Position) f32 { + const dx = b.x - a.x; + const dy = b.y - a.y; + return dx * dx + dy * dy; +} + +/// Manhattan (taxicab) distance +pub fn manhattan(a: Position, b: Position) f32 { + return @abs(b.x - a.x) + @abs(b.y - a.y); +} + +/// Chebyshev (chessboard) distance +pub fn chebyshev(a: Position, b: Position) f32 { + return @max(@abs(b.x - a.x), @abs(b.y - a.y)); +} + +/// Octile distance for 8-directional movement +pub fn octile(a: Position, b: Position) f32 { + const dx = @abs(b.x - a.x); + const dy = @abs(b.y - a.y); + return @max(dx, dy) + SQRT2_MINUS_1 * @min(dx, dy); +} diff --git a/src/hook_dispatcher.zig b/src/hook_dispatcher.zig new file mode 100644 index 0000000..133f5ed --- /dev/null +++ b/src/hook_dispatcher.zig @@ -0,0 +1,200 @@ +//! Hook Dispatcher +//! +//! Provides a comptime-based hook dispatcher for zero-overhead event handling. +//! Hooks are resolved entirely at compile time, with no runtime overhead. + +const std = @import("std"); + +/// Creates a hook dispatcher from a comptime hook map. +/// +/// The HookMap should be a struct type where each public declaration is either: +/// - A function matching the signature for that hook +/// - A function name matching a hook name (e.g., `game_init`, `scene_load`) +/// +/// Example: +/// ```zig +/// const MyHooks = struct { +/// pub fn game_init(payload: HookPayload) void { +/// // Handle game init +/// } +/// +/// pub fn scene_load(payload: HookPayload) void { +/// const info = payload.scene_load; +/// std.log.info("Scene loaded: {s}", .{info.name}); +/// } +/// }; +/// +/// const Dispatcher = HookDispatcher(EngineHook, HookPayload, MyHooks); +/// Dispatcher.emit(.{ .scene_load = .{ .name = "main" } }); +/// ``` +pub fn HookDispatcher( + comptime HookEnum: type, + comptime PayloadUnion: type, + comptime HookMap: type, +) type { + // Validate that PayloadUnion is a union tagged by HookEnum + const payload_info = @typeInfo(PayloadUnion); + if (payload_info != .@"union") { + @compileError("PayloadUnion must be a union type"); + } + if (payload_info.@"union".tag_type != HookEnum) { + @compileError("PayloadUnion must be tagged by HookEnum"); + } + + return struct { + const Self = @This(); + + /// The hook enum type this dispatcher handles. + pub const Hook = HookEnum; + + /// The payload union type this dispatcher handles. + pub const Payload = PayloadUnion; + + /// The hook handler map type. + pub const Handlers = HookMap; + + /// Emit a hook event. Resolved entirely at comptime - no runtime overhead. + /// + /// If no handler is registered for the hook, this is a no-op. + pub inline fn emit(payload: PayloadUnion) void { + // Use inline switch to resolve hook name at comptime + switch (payload) { + inline else => |_, tag| { + const hook_name = @tagName(tag); + if (@hasDecl(HookMap, hook_name)) { + const handler = @field(HookMap, hook_name); + handler(payload); + } + // No handler registered - that's fine, just a no-op + }, + } + } + + /// Check at comptime if a hook has a handler registered. + pub fn hasHandler(comptime hook: HookEnum) bool { + return @hasDecl(HookMap, @tagName(hook)); + } + + /// Get the number of hooks that have handlers registered. + pub fn handlerCount() comptime_int { + var count: comptime_int = 0; + for (std.enums.values(HookEnum)) |hook| { + if (@hasDecl(HookMap, @tagName(hook))) { + count += 1; + } + } + return count; + } + }; +} + +/// Creates an empty hook dispatcher with no handlers. +/// Useful as a default when no hooks are needed. +pub fn EmptyDispatcher(comptime HookEnum: type, comptime PayloadUnion: type) type { + return HookDispatcher(HookEnum, PayloadUnion, struct {}); +} + +/// Merges multiple hook handler structs into one composite dispatcher. +/// When a hook is emitted, all matching handlers from all structs are called in order. +/// +/// This enables two-way plugin binding: +/// - Plugins can provide engine hook handlers that get merged with game hooks +/// - Each handler struct can implement any subset of hooks +/// +/// Example: +/// ```zig +/// const GameHooks = struct { +/// pub fn game_init(_: HookPayload) void { +/// std.log.info("Game started!", .{}); +/// } +/// }; +/// +/// const PluginHooks = struct { +/// pub fn game_init(_: HookPayload) void { +/// std.log.info("Plugin initialized!", .{}); +/// } +/// pub fn frame_start(payload: HookPayload) void { +/// // Plugin frame logic +/// } +/// }; +/// +/// // Merge game + plugin hooks - both game_init handlers will be called +/// const AllHooks = MergeHooks(EngineHook, HookPayload, .{ GameHooks, PluginHooks }); +/// const Game = engine.GameWith(AllHooks); +/// ``` +pub fn MergeHooks( + comptime HookEnum: type, + comptime PayloadUnion: type, + comptime handler_structs: anytype, +) type { + // Validate that PayloadUnion is a union tagged by HookEnum + const payload_info = @typeInfo(PayloadUnion); + if (payload_info != .@"union") { + @compileError("PayloadUnion must be a union type"); + } + if (payload_info.@"union".tag_type != HookEnum) { + @compileError("PayloadUnion must be tagged by HookEnum"); + } + + return struct { + const Self = @This(); + + /// The hook enum type this dispatcher handles. + pub const Hook = HookEnum; + + /// The payload union type this dispatcher handles. + pub const Payload = PayloadUnion; + + /// Emit a hook event to all registered handlers. + /// Handlers are called in the order the structs appear in handler_structs. + /// If no handler is registered for the hook in any struct, this is a no-op. + pub inline fn emit(payload: PayloadUnion) void { + switch (payload) { + inline else => |_, tag| { + const hook_name = @tagName(tag); + inline for (handler_structs) |H| { + if (@hasDecl(H, hook_name)) { + const handler = @field(H, hook_name); + handler(payload); + } + } + }, + } + } + + /// Check at comptime if any handler struct has a handler for this hook. + pub fn hasHandler(comptime hook: HookEnum) bool { + inline for (handler_structs) |H| { + if (@hasDecl(H, @tagName(hook))) { + return true; + } + } + return false; + } + + /// Get the number of unique hooks that have at least one handler registered. + pub fn handlerCount() comptime_int { + var count: comptime_int = 0; + for (std.enums.values(HookEnum)) |hook| { + if (hasHandler(hook)) { + count += 1; + } + } + return count; + } + + /// Get the total number of handlers across all structs (including duplicates). + pub fn totalHandlerCount() comptime_int { + var count: comptime_int = 0; + for (std.enums.values(HookEnum)) |hook| { + const hook_name = @tagName(hook); + inline for (handler_structs) |H| { + if (@hasDecl(H, hook_name)) { + count += 1; + } + } + } + return count; + } + }; +} diff --git a/src/quad_tree.zig b/src/quad_tree.zig index de4158b..ddd99c3 100644 --- a/src/quad_tree.zig +++ b/src/quad_tree.zig @@ -77,12 +77,12 @@ pub fn EntityPoint(comptime T: type) type { } /// QuadTree node for internal storage -fn QuadTreeNode(comptime T: type) type { +fn QuadTreeNode(comptime T: type, comptime capacity: u32) type { const Point = EntityPoint(T); return struct { total_elements: u32 = 0, - points: [4]Point = undefined, + points: [capacity]Point = undefined, boundary: Rectangle, divided: bool = false, nw: u32 = 0, @@ -92,19 +92,27 @@ fn QuadTreeNode(comptime T: type) type { }; } +/// QuadTree configuration +pub const QuadTreeConfig = struct { + /// Maximum points per node before subdivision (default: 4) + capacity: u32 = 4, + /// Boundary margin for auto-computed bounds (default: 120.0) + gutter: f32 = 120.0, +}; + /// QuadTree for efficient spatial partitioning and queries /// -/// Generic over the ID type for flexibility (u32, u64, custom types) -pub fn QuadTree(comptime T: type) type { +/// Generic over: +/// - T: ID type for flexibility (u32, u64, custom types) +/// - config: Comptime configuration for capacity and gutter +pub fn QuadTree(comptime T: type, comptime config: QuadTreeConfig) type { const Point = EntityPoint(T); - const Node = QuadTreeNode(T); + const Node = QuadTreeNode(T, config.capacity); return struct { const Self = @This(); nodes: std.ArrayListUnmanaged(Node), - capacity: u32 = 4, - gutter: f32 = 120.0, lowest_x: f32 = 0.0, lowest_y: f32 = 0.0, @@ -164,20 +172,20 @@ pub fn QuadTree(comptime T: type) type { } try self.nodes.append(self.allocator, .{ .boundary = .{ - .x = self.lowest_x - self.gutter, - .y = self.lowest_y - self.gutter, - .width = (self.highest_x - self.lowest_x) + self.gutter * 2, - .height = (self.highest_y - self.lowest_y) + self.gutter * 2, + .x = self.lowest_x - config.gutter, + .y = self.lowest_y - config.gutter, + .width = (self.highest_x - self.lowest_x) + config.gutter * 2, + .height = (self.highest_y - self.lowest_y) + config.gutter * 2, } }); } /// Clear the tree keeping current boundaries pub fn reset(self: *Self) !void { const boundary = Rectangle{ - .x = self.lowest_x - self.gutter, - .y = self.lowest_y - self.gutter, - .width = (self.highest_x - self.lowest_x) + self.gutter * 2, - .height = (self.highest_y - self.lowest_y) + self.gutter * 2, + .x = self.lowest_x - config.gutter, + .y = self.lowest_y - config.gutter, + .width = (self.highest_x - self.lowest_x) + config.gutter * 2, + .height = (self.highest_y - self.lowest_y) + config.gutter * 2, }; self.nodes.clearRetainingCapacity(); self.lowest_x = std.math.inf(f32); @@ -202,7 +210,7 @@ pub fn QuadTree(comptime T: type) type { return false; } - if (self.nodes.items[node_idx].total_elements < self.capacity and !self.nodes.items[node_idx].divided) { + if (self.nodes.items[node_idx].total_elements < config.capacity and !self.nodes.items[node_idx].divided) { self.nodes.items[node_idx].points[self.nodes.items[node_idx].total_elements] = point; self.nodes.items[node_idx].total_elements += 1; return true; diff --git a/src/root.zig b/src/root.zig index bf6bb73..583a700 100644 --- a/src/root.zig +++ b/src/root.zig @@ -1,4 +1,4 @@ -// zig-utils - Standalone math utilities for Zig +// zig-utils - Standalone utilities for Zig // No external dependencies, only std pub const vector = @import("vector.zig"); @@ -7,6 +7,7 @@ pub const PositionI = vector.PositionI; pub const quad_tree = @import("quad_tree.zig"); pub const QuadTree = quad_tree.QuadTree; +pub const QuadTreeConfig = quad_tree.QuadTreeConfig; pub const EntityPoint = quad_tree.EntityPoint; pub const Rectangle = quad_tree.Rectangle; @@ -16,5 +17,39 @@ pub const AABB = sweep_and_prune.AABB; pub const CollisionPair = sweep_and_prune.CollisionPair; pub const sweepAndPruneSimple = sweep_and_prune.sweepAndPrune; +pub const sparse_set = @import("sparse_set.zig"); +pub const SparseSet = sparse_set.SparseSet; + +pub const z_index_buckets = @import("z_index_buckets.zig"); +pub const ZIndexBuckets = z_index_buckets.ZIndexBuckets; + +pub const zon = @import("zon_coercion.zig"); +pub const coerceValue = zon.coerceValue; +pub const buildStruct = zon.buildStruct; +pub const tupleToSlice = zon.tupleToSlice; +pub const mergeStructs = zon.mergeStructs; + +pub const hooks = @import("hook_dispatcher.zig"); +pub const HookDispatcher = hooks.HookDispatcher; +pub const EmptyDispatcher = hooks.EmptyDispatcher; +pub const MergeHooks = hooks.MergeHooks; + +// Graph algorithms +pub const floyd_warshall = @import("floyd_warshall.zig"); +pub const FloydWarshall = floyd_warshall.FloydWarshall; + +pub const floyd_warshall_optimized = @import("floyd_warshall_optimized.zig"); +pub const FloydWarshallOptimized = floyd_warshall_optimized.FloydWarshallOptimized; +pub const FloydWarshallConfig = floyd_warshall_optimized.Config; +pub const FloydWarshallParallel = floyd_warshall_optimized.FloydWarshallParallel; +pub const FloydWarshallSimd = floyd_warshall_optimized.FloydWarshallSimd; +pub const FloydWarshallScalar = floyd_warshall_optimized.FloydWarshallScalar; + +pub const a_star = @import("a_star.zig"); +pub const AStar = a_star.AStar; + +pub const heuristics = @import("heuristics.zig"); +pub const Heuristic = heuristics.Heuristic; + // Backwards compatibility alias (deprecated) pub const Vector2 = Position; diff --git a/src/sparse_set.zig b/src/sparse_set.zig new file mode 100644 index 0000000..a50faaa --- /dev/null +++ b/src/sparse_set.zig @@ -0,0 +1,190 @@ +//! Sparse Set +//! +//! O(1) lookup, insert, remove with cache-friendly iteration. +//! Used for entity -> physics body mappings. + +const std = @import("std"); +const Allocator = std.mem.Allocator; + +/// Generic sparse set for mapping keys to values +/// KeyType must be an unsigned integer type (u8, u16, u32, u64, usize) +pub fn SparseSet(comptime KeyType: type, comptime T: type) type { + comptime { + const info = @typeInfo(KeyType); + if (info != .int or info.int.signedness != .unsigned) { + @compileError("KeyType must be an unsigned integer type"); + } + } + + return struct { + const Self = @This(); + + allocator: Allocator, + sparse: []?u32, // key -> dense_index + dense_keys: []KeyType, // dense_index -> key + dense_values: []T, // dense_index -> value + count: usize, + capacity: usize, + max_key: usize, + + pub fn init(allocator: Allocator, max_keys: usize, initial_capacity: usize) !Self { + const sparse = try allocator.alloc(?u32, max_keys); + errdefer allocator.free(sparse); + @memset(sparse, null); + + const dense_keys = try allocator.alloc(KeyType, initial_capacity); + errdefer allocator.free(dense_keys); + + const dense_values = try allocator.alloc(T, initial_capacity); + errdefer allocator.free(dense_values); + + return Self{ + .allocator = allocator, + .sparse = sparse, + .dense_keys = dense_keys, + .dense_values = dense_values, + .count = 0, + .capacity = initial_capacity, + .max_key = max_keys, + }; + } + + pub fn deinit(self: *Self) void { + self.allocator.free(self.sparse); + self.allocator.free(self.dense_keys); + self.allocator.free(self.dense_values); + } + + /// Insert or update a key-value pair + pub fn put(self: *Self, key: KeyType, value: T) !void { + if (key >= self.max_key) return error.KeyOutOfRange; + + // Update existing + if (self.sparse[key]) |idx| { + self.dense_values[idx] = value; + return; + } + + // Grow if needed + if (self.count >= self.capacity) { + const new_cap = if (self.capacity == 0) 4 else self.capacity * 2; + try self.grow(new_cap); + } + + if (self.count >= std.math.maxInt(u32)) return error.CapacityExceeded; + const idx: u32 = @intCast(self.count); + self.sparse[key] = idx; + self.dense_keys[idx] = key; + self.dense_values[idx] = value; + self.count += 1; + } + + /// Atomically grow both dense arrays + fn grow(self: *Self, new_cap: usize) !void { + // Allocate new arrays first (no state change yet) + const new_keys = try self.allocator.alloc(KeyType, new_cap); + errdefer self.allocator.free(new_keys); + + const new_values = try self.allocator.alloc(T, new_cap); + + // Copy existing data + @memcpy(new_keys[0..self.count], self.dense_keys[0..self.count]); + @memcpy(new_values[0..self.count], self.dense_values[0..self.count]); + + // Free old arrays + self.allocator.free(self.dense_keys); + self.allocator.free(self.dense_values); + + // Update state atomically + self.dense_keys = new_keys; + self.dense_values = new_values; + self.capacity = new_cap; + } + + /// Get value for key + pub fn get(self: *const Self, key: KeyType) ?T { + if (key >= self.max_key) return null; + const idx = self.sparse[key] orelse return null; + return self.dense_values[idx]; + } + + /// Get pointer to value for key + pub fn getPtr(self: *Self, key: KeyType) ?*T { + if (key >= self.max_key) return null; + const idx = self.sparse[key] orelse return null; + return &self.dense_values[idx]; + } + + /// Check if key exists + pub fn contains(self: *const Self, key: KeyType) bool { + if (key >= self.max_key) return false; + return self.sparse[key] != null; + } + + /// Remove key-value pair + pub fn remove(self: *Self, key: KeyType) void { + if (key >= self.max_key) return; + const idx = self.sparse[key] orelse return; + + // Swap with last element + const last_idx = self.count - 1; + if (idx != last_idx) { + const last_key = self.dense_keys[last_idx]; + self.dense_keys[idx] = last_key; + self.dense_values[idx] = self.dense_values[last_idx]; + self.sparse[last_key] = idx; + } + + self.sparse[key] = null; + self.count -= 1; + } + + /// Clear all entries + pub fn clear(self: *Self) void { + for (self.dense_keys[0..self.count]) |key| { + self.sparse[key] = null; + } + self.count = 0; + } + + /// Iterate over all values (cache-friendly) + pub fn values(self: *const Self) []const T { + return self.dense_values[0..self.count]; + } + + /// Iterate over all keys + pub fn keys(self: *const Self) []const KeyType { + return self.dense_keys[0..self.count]; + } + + /// Get key-value pairs for iteration + pub const Entry = struct { + key: KeyType, + value: *T, + }; + + pub fn iterator(self: *Self) Iterator { + return .{ .set = self, .index = 0 }; + } + + pub const Iterator = struct { + set: *Self, + index: usize, + + pub fn next(self: *Iterator) ?Entry { + if (self.index >= self.set.count) return null; + const entry = Entry{ + .key = self.set.dense_keys[self.index], + .value = &self.set.dense_values[self.index], + }; + self.index += 1; + return entry; + } + }; + + /// Number of entries + pub fn len(self: *const Self) usize { + return self.count; + } + }; +} diff --git a/src/z_index_buckets.zig b/src/z_index_buckets.zig new file mode 100644 index 0000000..10e6f28 --- /dev/null +++ b/src/z_index_buckets.zig @@ -0,0 +1,183 @@ +//! Z-Index Bucket Storage +//! +//! Maintains items sorted by z-index using configurable buckets. +//! This eliminates the need to re-sort the entire list when z-indices change. +//! +//! Complexity: +//! - Insert: O(1) amortized +//! - Remove: O(bucket_size) - typically small due to clustered z-indices +//! - Change z-index: O(bucket_size) +//! - Iteration: O(bucket_count + n) ≈ O(n) + +const std = @import("std"); + +/// Z-index bucket storage for efficient ordered iteration. +/// Generic over: +/// - T: item type +/// - ZIndexType: unsigned integer type for z-index (u8, u16, etc.) +/// +/// The number of buckets is determined by the max value of ZIndexType + 1. +/// For u8: 256 buckets, for u4: 16 buckets, etc. +pub fn ZIndexBuckets(comptime T: type, comptime ZIndexType: type) type { + comptime { + const info = @typeInfo(ZIndexType); + if (info != .int or info.int.signedness != .unsigned) { + @compileError("ZIndexType must be an unsigned integer type"); + } + } + + const bucket_count = std.math.maxInt(ZIndexType) + 1; + + return struct { + const Self = @This(); + const Bucket = std.ArrayListUnmanaged(T); + + buckets: [bucket_count]Bucket, + allocator: std.mem.Allocator, + total_count: usize, + + pub fn init(allocator: std.mem.Allocator) Self { + return Self{ + .buckets = [_]Bucket{.{}} ** bucket_count, + .allocator = allocator, + .total_count = 0, + }; + } + + pub fn deinit(self: *Self) void { + for (&self.buckets) |*bucket| { + bucket.deinit(self.allocator); + } + } + + /// Insert an item at the given z-index + pub fn insert(self: *Self, item: T, z: ZIndexType) !void { + try self.buckets[z].append(self.allocator, item); + self.total_count += 1; + } + + /// Remove an item from the given z-index bucket using equality comparison. + /// Returns true if the item was found and removed. + pub fn remove(self: *Self, item: T, z: ZIndexType) bool { + const bucket = &self.buckets[z]; + for (bucket.items, 0..) |existing, i| { + if (eql(existing, item)) { + _ = bucket.swapRemove(i); + self.total_count -= 1; + return true; + } + } + return false; + } + + /// Change an item's z-index from old_z to new_z + /// Returns error if the item was not found at old_z or allocation fails + pub fn changeZIndex(self: *Self, item: T, old_z: ZIndexType, new_z: ZIndexType) !void { + if (old_z == new_z) return; + + // First verify item exists at old_z before making any changes + const bucket = &self.buckets[old_z]; + var found_index: ?usize = null; + for (bucket.items, 0..) |existing, i| { + if (eql(existing, item)) { + found_index = i; + break; + } + } + if (found_index == null) { + return error.ItemNotFound; + } + + // Insert to new bucket first - if this fails, no state has changed + try self.buckets[new_z].append(self.allocator, item); + + // Now safe to remove from old bucket (insert succeeded) + _ = bucket.swapRemove(found_index.?); + // total_count stays the same (removed one, added one) + } + + /// Get total number of items across all buckets + pub fn count(self: *const Self) usize { + return self.total_count; + } + + /// Clear all buckets + pub fn clear(self: *Self) void { + for (&self.buckets) |*bucket| { + bucket.clearRetainingCapacity(); + } + self.total_count = 0; + } + + /// Iterator that yields items in z-index order (0 to max) + pub fn iterator(self: *const Self) Iterator { + return Iterator.init(self); + } + + pub const Iterator = struct { + buckets: *const [bucket_count]Bucket, + z: usize, + idx: usize, + + pub fn init(storage: *const Self) Iterator { + var iter = Iterator{ + .buckets = &storage.buckets, + .z = 0, + .idx = 0, + }; + iter.skipEmptyBuckets(); + return iter; + } + + pub fn next(self: *Iterator) ?T { + while (self.z < bucket_count) { + const bucket = &self.buckets[self.z]; + if (self.idx < bucket.items.len) { + const item = bucket.items[self.idx]; + self.idx += 1; + return item; + } + self.z += 1; + self.idx = 0; + } + return null; + } + + fn skipEmptyBuckets(self: *Iterator) void { + while (self.z < bucket_count and self.buckets[self.z].items.len == 0) { + self.z += 1; + } + } + + pub fn reset(self: *Iterator) void { + self.z = 0; + self.idx = 0; + self.skipEmptyBuckets(); + } + }; + + /// Collect all items into a slice in z-index order. + /// The caller must provide a buffer of at least `count()` size. + pub fn collectInto(self: *const Self, buffer: []T) []T { + var i: usize = 0; + var iter = self.iterator(); + while (iter.next()) |item| { + if (i >= buffer.len) break; + buffer[i] = item; + i += 1; + } + return buffer[0..i]; + } + + /// Equality comparison for items + fn eql(a: T, b: T) bool { + // Use eql method if available (for structs), otherwise use std.meta.eql + const info = @typeInfo(T); + if (info == .@"struct" and @hasDecl(T, "eql")) { + return a.eql(b); + } else { + return std.meta.eql(a, b); + } + } + }; +} diff --git a/src/zon_coercion.zig b/src/zon_coercion.zig new file mode 100644 index 0000000..a40857d --- /dev/null +++ b/src/zon_coercion.zig @@ -0,0 +1,315 @@ +//! Comptime ZON coercion utilities +//! +//! Converts anonymous structs from .zon files to typed structs at comptime. +//! Handles nested struct coercion, tuple-to-slice conversion, and union coercion. +//! +//! Usage: +//! const MyType = zon.buildStruct(TargetType, zon_data); +//! const field_val = zon.coerceValue(FieldType, zon_value); + +const std = @import("std"); + +/// Coerce a comptime ZON value to the expected field type. +/// Handles nested struct coercion and tuple-to-slice conversion. +pub fn coerceValue(comptime FieldType: type, comptime data_value: anytype) FieldType { + const DataType = @TypeOf(data_value); + const field_info = @typeInfo(FieldType); + + // Handle optional types - unwrap and coerce the child type + if (field_info == .optional) { + const ChildType = field_info.optional.child; + // Check for null + if (DataType == @TypeOf(null)) { + return null; + } + // Coerce to the child type and wrap in optional + return coerceValue(ChildType, data_value); + } + + // Handle slice types + if (field_info == .pointer) { + const ptr_info = field_info.pointer; + if (ptr_info.size == .slice) { + const ChildType = ptr_info.child; + const data_info = @typeInfo(DataType); + + // If data is a tuple, convert to slice + if (data_info == .@"struct" and data_info.@"struct".is_tuple) { + return tupleToSlice(ChildType, data_value); + } + } + } + + // Handle fixed-size array coercion (tuple to array) + if (field_info == .array) { + const arr_info = field_info.array; + const data_info = @typeInfo(DataType); + if (data_info == .@"struct" and data_info.@"struct".is_tuple) { + const tuple_len = data_info.@"struct".fields.len; + if (tuple_len != arr_info.len) { + @compileError(std.fmt.comptimePrint( + "Array size mismatch: expected {d} elements, got {d}", + .{ arr_info.len, tuple_len }, + )); + } + var array: [arr_info.len]arr_info.child = undefined; + inline for (0..arr_info.len) |i| { + array[i] = coerceValue(arr_info.child, data_value[i]); + } + return array; + } + } + + // Handle tagged union coercion from anonymous struct + // Example: .{ .box = .{ .width = 50, .height = 50 } } -> Shape union + if (field_info == .@"union") { + return coerceToUnion(FieldType, data_value); + } + + // Handle nested struct coercion + if (field_info == .@"struct" and @typeInfo(DataType) == .@"struct") { + return buildStruct(FieldType, data_value); + } + + // Direct assignment for compatible types + return data_value; +} + +/// Coerce a comptime value to a tagged union type. +/// Supports: +/// - Single-field anonymous struct: .{ .box = .{ .width = 50 } } -> Union.box +/// - Enum literal for void payloads: .idle -> State.idle +/// - Payload-matching struct: .{ .width = 50, .height = 50 } -> Container.explicit (if fields match) +fn coerceToUnion(comptime UnionType: type, comptime data_value: anytype) UnionType { + const DataType = @TypeOf(data_value); + const data_info = @typeInfo(DataType); + const union_info = @typeInfo(UnionType).@"union"; + + // Case 1: Enum literal for void payload variants + // Example: .idle -> State.idle (where State = union(enum) { idle, running, ... }) + if (data_info == .enum_literal) { + const tag_name = @tagName(data_value); + + inline for (union_info.fields) |union_field| { + if (comptime std.mem.eql(u8, union_field.name, tag_name)) { + // Verify the payload is void + if (union_field.type != void) { + @compileError("Cannot use enum literal for union variant '" ++ tag_name ++ + "' with non-void payload. Use .{ ." ++ tag_name ++ " = ... } syntax instead."); + } + return @unionInit(UnionType, tag_name, {}); + } + } + @compileError("No union variant named '" ++ tag_name ++ "' in " ++ @typeName(UnionType)); + } + + // Case 2: Anonymous struct - could be variant selector or direct payload + if (data_info == .@"struct") { + const data_fields = data_info.@"struct".fields; + + // Case 2a: Single-field struct where field name matches a union variant + // Example: .{ .box = .{ .width = 50, .height = 50 } } -> Shape.box + if (data_fields.len == 1) { + const field_name = data_fields[0].name; + + // Check if this field name matches a union variant + inline for (union_info.fields) |union_field| { + if (comptime std.mem.eql(u8, union_field.name, field_name)) { + // This is the variant selector pattern + const variant_value = @field(data_value, field_name); + const coerced_payload = coerceValue(union_field.type, variant_value); + return @unionInit(UnionType, field_name, coerced_payload); + } + } + } + + // Case 2b: Multi-field struct that matches a union variant's payload type + // Example: .{ .width = 400, .height = 300 } -> Container.explicit + inline for (union_info.fields) |union_field| { + const payload_info = @typeInfo(union_field.type); + if (payload_info == .@"struct") { + // Check if data fields are compatible with this payload struct + if (comptime structFieldsCompatible(DataType, union_field.type)) { + const coerced_payload = buildStruct(union_field.type, data_value); + return @unionInit(UnionType, union_field.name, coerced_payload); + } + } + } + + @compileError("Cannot coerce struct to union type " ++ @typeName(UnionType) ++ + ". Use .{ .variant_name = payload } syntax or ensure struct fields match a variant's payload."); + } + + // Case 3: Direct assignment if types match + if (DataType == UnionType) { + return data_value; + } + + @compileError("Cannot coerce " ++ @typeName(DataType) ++ " to union type " ++ @typeName(UnionType) ++ + ". Use .{ .variant_name = payload } or .variant_name (for void payloads)."); +} + +/// Check if all fields in DataType exist in TargetType (for struct compatibility) +fn structFieldsCompatible(comptime DataType: type, comptime TargetType: type) bool { + const data_fields = @typeInfo(DataType).@"struct".fields; + const target_fields = @typeInfo(TargetType).@"struct".fields; + + // All data fields must exist in target + for (data_fields) |df| { + var found = false; + for (target_fields) |tf| { + if (std.mem.eql(u8, df.name, tf.name)) { + found = true; + break; + } + } + if (!found) return false; + } + + return data_fields.len > 0; +} + +/// Build a struct from comptime anonymous struct data. +/// Recursively coerces nested fields. +/// Raises compile error for missing required fields (fields without defaults). +pub fn buildStruct(comptime StructType: type, comptime data: anytype) StructType { + return buildStructWithContext(StructType, data, "struct"); +} + +/// Build a struct with a custom context string for error messages. +fn buildStructWithContext(comptime StructType: type, comptime data: anytype, comptime context: []const u8) StructType { + const fields = std.meta.fields(StructType); + var result: StructType = undefined; + + inline for (fields) |field| { + if (@hasField(@TypeOf(data), field.name)) { + const data_value = @field(data, field.name); + @field(result, field.name) = coerceValue(field.type, data_value); + } else if (field.default_value_ptr) |ptr| { + const default_ptr: *const field.type = @ptrCast(@alignCast(ptr)); + @field(result, field.name) = default_ptr.*; + } else { + @compileError("Missing required field '" ++ field.name ++ "' for " ++ context ++ " '" ++ @typeName(StructType) ++ "'"); + } + } + + return result; +} + +/// Convert a tuple to a slice at comptime. +/// Recursively coerces each element. +pub fn tupleToSlice(comptime ChildType: type, comptime tuple: anytype) []const ChildType { + const tuple_info = @typeInfo(@TypeOf(tuple)).@"struct"; + const len = tuple_info.fields.len; + + const array = comptime blk: { + var arr: [len]ChildType = undefined; + for (0..len) |i| { + arr[i] = coerceValue(ChildType, tuple[i]); + } + break :blk arr; + }; + + return &array; +} + +/// Merge two comptime structs, with overrides taking precedence. +/// Returns a new anonymous struct with all fields from base, plus any +/// fields from overrides (which override base values). +/// +/// Example: +/// base = .{ .x = 10, .y = 20, .color = .red } +/// overrides = .{ .color = .blue } +/// result = .{ .x = 10, .y = 20, .color = .blue } +pub fn mergeStructs(comptime base: anytype, comptime overrides: anytype) MergedStructType(@TypeOf(base), @TypeOf(overrides)) { + const BaseType = @TypeOf(base); + const OverridesType = @TypeOf(overrides); + + var result: MergedStructType(BaseType, OverridesType) = undefined; + + // Copy all fields from base + inline for (std.meta.fields(BaseType)) |field| { + if (@hasField(OverridesType, field.name)) { + @field(result, field.name) = @field(overrides, field.name); + } else { + @field(result, field.name) = @field(base, field.name); + } + } + + // Add fields that exist only in overrides (not in base) + inline for (std.meta.fields(OverridesType)) |field| { + if (!@hasField(BaseType, field.name)) { + @field(result, field.name) = @field(overrides, field.name); + } + } + + return result; +} + +/// Compute the merged struct type from two struct types. +fn MergedStructType(comptime BaseType: type, comptime OverridesType: type) type { + const base_fields = std.meta.fields(BaseType); + const override_fields = std.meta.fields(OverridesType); + + comptime var field_count = base_fields.len; + inline for (override_fields) |of| { + if (!@hasField(BaseType, of.name)) { + field_count += 1; + } + } + + comptime var fields: [field_count]std.builtin.Type.StructField = undefined; + comptime var i = 0; + + inline for (base_fields) |bf| { + if (@hasField(OverridesType, bf.name)) { + inline for (override_fields) |of| { + if (comptime std.mem.eql(u8, of.name, bf.name)) { + fields[i] = .{ + .name = bf.name, + .type = of.type, + .default_value_ptr = null, + .is_comptime = false, + .alignment = @alignOf(of.type), + }; + } + } + } else { + fields[i] = .{ + .name = bf.name, + .type = bf.type, + .default_value_ptr = null, + .is_comptime = false, + .alignment = @alignOf(bf.type), + }; + } + i += 1; + } + + inline for (override_fields) |of| { + if (!@hasField(BaseType, of.name)) { + fields[i] = .{ + .name = of.name, + .type = of.type, + .default_value_ptr = null, + .is_comptime = false, + .alignment = @alignOf(of.type), + }; + i += 1; + } + } + + return @Type(.{ + .@"struct" = .{ + .layout = .auto, + .fields = &fields, + .decls = &.{}, + .is_tuple = false, + }, + }); +} + +/// Check if a struct type has any fields +pub fn hasFields(comptime T: type) bool { + return std.meta.fields(T).len > 0; +} diff --git a/tests/a_star_test.zig b/tests/a_star_test.zig new file mode 100644 index 0000000..54f38e9 --- /dev/null +++ b/tests/a_star_test.zig @@ -0,0 +1,119 @@ +const std = @import("std"); +const expect = @import("zspec").expect; +const zig_utils = @import("zig_utils"); +const AStar = zig_utils.AStar; + +pub const AStarSpec = struct { + pub const basic_pathfinding = struct { + test "finds path in linear graph" { + const allocator = std.testing.allocator; + + var astar = try AStar(u64).init(allocator); + defer astar.deinit(); + + astar.resize(4); + try astar.clean(); + + // Set positions for heuristic + try astar.setNodePosition(0, .{ .x = 0, .y = 0 }); + try astar.setNodePosition(1, .{ .x = 1, .y = 0 }); + try astar.setNodePosition(2, .{ .x = 2, .y = 0 }); + try astar.setNodePosition(3, .{ .x = 3, .y = 0 }); + + // Create graph: 0 -> 1 -> 2 -> 3 + try astar.addEdge(0, 1, 1); + try astar.addEdge(1, 2, 1); + try astar.addEdge(2, 3, 1); + + var path = std.array_list.Managed(u32).init(allocator); + defer path.deinit(); + + const cost = try astar.findPath(0, 3, &path); + + try expect.equal(cost.?, 3); + try expect.equal(path.items.len, 4); + try expect.equal(path.items[0], 0); + try expect.equal(path.items[1], 1); + try expect.equal(path.items[2], 2); + try expect.equal(path.items[3], 3); + } + }; + + pub const weighted_shortest_path = struct { + test "finds shorter weighted path" { + const allocator = std.testing.allocator; + + var astar = try AStar(u64).init(allocator); + defer astar.deinit(); + + astar.resize(4); + try astar.clean(); + astar.setHeuristic(.zero); // Use Dijkstra for testing weighted paths + + // Graph with two paths to node 3: + // 0 --5--> 1 --3--> 3 (total: 8) + // 0 --2--> 2 --2--> 3 (total: 4) <- shorter + try astar.addEdge(0, 1, 5); + try astar.addEdge(1, 3, 3); + try astar.addEdge(0, 2, 2); + try astar.addEdge(2, 3, 2); + + var path = std.array_list.Managed(u32).init(allocator); + defer path.deinit(); + + const cost = try astar.findPath(0, 3, &path); + + try expect.equal(cost.?, 4); + try expect.equal(path.items.len, 3); + try expect.equal(path.items[0], 0); + try expect.equal(path.items[1], 2); + try expect.equal(path.items[2], 3); + } + }; + + pub const no_path = struct { + test "returns null when no path exists" { + const allocator = std.testing.allocator; + + var astar = try AStar(u64).init(allocator); + defer astar.deinit(); + + astar.resize(4); + try astar.clean(); + + // Disconnected graph: 0 -> 1, 2 -> 3 (no path from 0 to 3) + try astar.addEdge(0, 1, 1); + try astar.addEdge(2, 3, 1); + + var path = std.array_list.Managed(u32).init(allocator); + defer path.deinit(); + + const cost = try astar.findPath(0, 3, &path); + + try expect.toBeTrue(cost == null); + } + }; + + pub const different_weight_types = struct { + test "works with u32 weights" { + const allocator = std.testing.allocator; + + var astar = try AStar(u32).init(allocator); + defer astar.deinit(); + + astar.resize(3); + try astar.clean(); + astar.setHeuristic(.zero); + + try astar.addEdge(0, 1, 10); + try astar.addEdge(1, 2, 20); + + var path = std.array_list.Managed(u32).init(allocator); + defer path.deinit(); + + const cost = try astar.findPath(0, 2, &path); + + try expect.equal(cost.?, 30); + } + }; +}; diff --git a/tests/floyd_warshall_optimized_test.zig b/tests/floyd_warshall_optimized_test.zig new file mode 100644 index 0000000..ca9c887 --- /dev/null +++ b/tests/floyd_warshall_optimized_test.zig @@ -0,0 +1,130 @@ +const std = @import("std"); +const expect = @import("zspec").expect; +const zig_utils = @import("zig_utils"); +const FloydWarshallSimd = zig_utils.FloydWarshallSimd; + +pub const FloydWarshallOptimizedSpec = struct { + pub const basic_functionality = struct { + test "computes distances for linear graph" { + const allocator = std.testing.allocator; + + var fw = FloydWarshallSimd.init(allocator); + defer fw.deinit(); + + fw.resize(4); + try fw.clean(); + + // Create graph: 0 -> 1 -> 2 -> 3 + fw.addEdge(0, 1, 1); + fw.addEdge(1, 2, 1); + fw.addEdge(2, 3, 1); + + fw.generate(); + + try expect.equal(fw.value(0, 0), 0); + try expect.equal(fw.value(0, 1), 1); + try expect.equal(fw.value(0, 2), 2); + try expect.equal(fw.value(0, 3), 3); + } + + test "computes next hops correctly" { + const allocator = std.testing.allocator; + + var fw = FloydWarshallSimd.init(allocator); + defer fw.deinit(); + + fw.resize(4); + try fw.clean(); + + fw.addEdge(0, 1, 1); + fw.addEdge(1, 2, 1); + fw.addEdge(2, 3, 1); + + fw.generate(); + + try expect.equal(fw.getNext(0, 3), 1); + try expect.equal(fw.getNext(1, 3), 2); + } + }; + + pub const entity_mapping = struct { + test "works with entity ID mapping" { + const allocator = std.testing.allocator; + + var fw = FloydWarshallSimd.init(allocator); + defer fw.deinit(); + + fw.resize(4); + try fw.clean(); + + // Use entity IDs: 100 -> 200 -> 300 -> 400 + try fw.addEdgeWithMapping(100, 200, 1); + try fw.addEdgeWithMapping(200, 300, 1); + try fw.addEdgeWithMapping(300, 400, 1); + + fw.generate(); + + try expect.toBeTrue(fw.hasPathWithMapping(100, 400)); + try expect.toBeTrue(fw.hasPathWithMapping(100, 200)); + + try expect.equal(fw.valueWithMapping(100, 200), 1); + try expect.equal(fw.valueWithMapping(100, 400), 3); + + try expect.equal(fw.nextWithMapping(100, 400), 200); + } + }; + + pub const weighted_shortest_path = struct { + test "finds shorter path through intermediate node" { + const allocator = std.testing.allocator; + + var fw = FloydWarshallSimd.init(allocator); + defer fw.deinit(); + + fw.resize(4); + try fw.clean(); + + // Graph with two paths to node 3: + // 0 --5--> 1 --3--> 3 (total: 8) + // 0 --2--> 2 --2--> 3 (total: 4) <- shorter + fw.addEdge(0, 1, 5); + fw.addEdge(1, 3, 3); + fw.addEdge(0, 2, 2); + fw.addEdge(2, 3, 2); + + fw.generate(); + + try expect.equal(fw.value(0, 3), 4); + try expect.equal(fw.getNext(0, 3), 2); + } + }; + + pub const path_reconstruction = struct { + test "reconstructs path correctly" { + const allocator = std.testing.allocator; + + var fw = FloydWarshallSimd.init(allocator); + defer fw.deinit(); + + fw.resize(4); + try fw.clean(); + + try fw.addEdgeWithMapping(10, 20, 1); + try fw.addEdgeWithMapping(20, 30, 1); + try fw.addEdgeWithMapping(30, 40, 1); + + fw.generate(); + + var path = std.ArrayListUnmanaged(u32){}; + defer path.deinit(allocator); + + try fw.setPathWithMappingUnmanaged(allocator, &path, 10, 40); + + try expect.equal(path.items.len, 4); + try expect.equal(path.items[0], 10); + try expect.equal(path.items[1], 20); + try expect.equal(path.items[2], 30); + try expect.equal(path.items[3], 40); + } + }; +}; diff --git a/tests/floyd_warshall_test.zig b/tests/floyd_warshall_test.zig new file mode 100644 index 0000000..f0dce37 --- /dev/null +++ b/tests/floyd_warshall_test.zig @@ -0,0 +1,93 @@ +const std = @import("std"); +const expect = @import("zspec").expect; +const zig_utils = @import("zig_utils"); +const FloydWarshall = zig_utils.FloydWarshall; + +pub const FloydWarshallSpec = struct { + pub const basic_functionality = struct { + test "computes distances for linear graph" { + const allocator = std.testing.allocator; + + var fw = FloydWarshall(u64).init(allocator); + defer fw.deinit(); + + fw.resize(4); + try fw.clean(); + + // Create graph: 0 -> 1 -> 2 -> 3 + fw.addEdge(0, 1, 1); + fw.addEdge(1, 2, 1); + fw.addEdge(2, 3, 1); + + fw.generate(); + + try expect.equal(fw.value(0, 0), 0); + try expect.equal(fw.value(0, 1), 1); + try expect.equal(fw.value(0, 2), 2); + try expect.equal(fw.value(0, 3), 3); + } + + test "computes next hops correctly" { + const allocator = std.testing.allocator; + + var fw = FloydWarshall(u64).init(allocator); + defer fw.deinit(); + + fw.resize(4); + try fw.clean(); + + fw.addEdge(0, 1, 1); + fw.addEdge(1, 2, 1); + fw.addEdge(2, 3, 1); + + fw.generate(); + + try expect.equal(fw.next(0, 3), 1); + try expect.equal(fw.next(1, 3), 2); + } + }; + + pub const weighted_shortest_path = struct { + test "finds shorter path through intermediate node" { + const allocator = std.testing.allocator; + + var fw = FloydWarshall(u64).init(allocator); + defer fw.deinit(); + + fw.resize(4); + try fw.clean(); + + // Graph with two paths to node 3: + // 0 --5--> 1 --3--> 3 (total: 8) + // 0 --2--> 2 --2--> 3 (total: 4) <- shorter + fw.addEdge(0, 1, 5); + fw.addEdge(1, 3, 3); + fw.addEdge(0, 2, 2); + fw.addEdge(2, 3, 2); + + fw.generate(); + + try expect.equal(fw.value(0, 3), 4); + try expect.equal(fw.next(0, 3), 2); + } + }; + + pub const different_distance_types = struct { + test "works with u32 distances" { + const allocator = std.testing.allocator; + + var fw = FloydWarshall(u32).init(allocator); + defer fw.deinit(); + + fw.resize(3); + try fw.clean(); + + fw.addEdge(0, 1, 10); + fw.addEdge(1, 2, 20); + + fw.generate(); + + try expect.equal(fw.value(0, 2), 30); + } + }; +}; diff --git a/tests/heuristics_test.zig b/tests/heuristics_test.zig new file mode 100644 index 0000000..044445a --- /dev/null +++ b/tests/heuristics_test.zig @@ -0,0 +1,63 @@ +const std = @import("std"); +const expect = @import("zspec").expect; +const zig_utils = @import("zig_utils"); +const heuristics = zig_utils.heuristics; +const Position = zig_utils.Position; + +pub const HeuristicsSpec = struct { + pub const euclidean = struct { + test "calculates euclidean distance" { + const a = Position{ .x = 0, .y = 0 }; + const b = Position{ .x = 3, .y = 4 }; + + const result = heuristics.euclidean(a, b); + + try expect.toBeTrue(@abs(result - 5.0) < 0.001); + } + }; + + pub const manhattan = struct { + test "calculates manhattan distance" { + const a = Position{ .x = 0, .y = 0 }; + const b = Position{ .x = 3, .y = 4 }; + + const result = heuristics.manhattan(a, b); + + try expect.toBeTrue(@abs(result - 7.0) < 0.001); + } + }; + + pub const chebyshev = struct { + test "calculates chebyshev distance" { + const a = Position{ .x = 0, .y = 0 }; + const b = Position{ .x = 3, .y = 4 }; + + const result = heuristics.chebyshev(a, b); + + try expect.toBeTrue(@abs(result - 4.0) < 0.001); + } + }; + + pub const octile = struct { + test "calculates octile distance" { + const a = Position{ .x = 0, .y = 0 }; + const b = Position{ .x = 3, .y = 4 }; + + const result = heuristics.octile(a, b); + // 4 + (sqrt(2)-1) * 3 = 4 + 0.414 * 3 = 5.243 + + try expect.toBeTrue(@abs(result - 5.243) < 0.01); + } + }; + + pub const calculate_with_enum = struct { + test "calculates using heuristic enum" { + const a = Position{ .x = 0, .y = 0 }; + const b = Position{ .x = 3, .y = 4 }; + + try expect.toBeTrue(@abs(heuristics.calculate(.euclidean, a, b) - 5.0) < 0.001); + try expect.toBeTrue(@abs(heuristics.calculate(.manhattan, a, b) - 7.0) < 0.001); + try expect.toBeTrue(@abs(heuristics.calculate(.zero, a, b) - 0.0) < 0.001); + } + }; +}; diff --git a/tests/quad_tree_test.zig b/tests/quad_tree_test.zig index ea0693f..966bfe2 100644 --- a/tests/quad_tree_test.zig +++ b/tests/quad_tree_test.zig @@ -78,7 +78,7 @@ pub const RectangleSpec = struct { }; pub const QuadTreeSpec = struct { - const QT = QuadTree(u32); + const QT = QuadTree(u32, .{}); const Point = EntityPoint(u32); pub const init = struct { diff --git a/tests/root.zig b/tests/root.zig index b02674b..383ca63 100644 --- a/tests/root.zig +++ b/tests/root.zig @@ -3,6 +3,13 @@ const std = @import("std"); pub const vector_test = @import("vector_test.zig"); pub const quad_tree_test = @import("quad_tree_test.zig"); pub const sweep_and_prune_test = @import("sweep_and_prune_test.zig"); +pub const sparse_set_test = @import("sparse_set_test.zig"); +pub const z_index_buckets_test = @import("z_index_buckets_test.zig"); +pub const floyd_warshall_test = @import("floyd_warshall_test.zig"); +pub const floyd_warshall_optimized_test = @import("floyd_warshall_optimized_test.zig"); +pub const a_star_test = @import("a_star_test.zig"); +pub const heuristics_test = @import("heuristics_test.zig"); +pub const zon_coercion_test = @import("zon_coercion_test.zig"); test { std.testing.refAllDeclsRecursive(@This()); diff --git a/tests/sparse_set_test.zig b/tests/sparse_set_test.zig new file mode 100644 index 0000000..e33c07a --- /dev/null +++ b/tests/sparse_set_test.zig @@ -0,0 +1,127 @@ +const std = @import("std"); +const expect = @import("zspec").expect; +const zig_utils = @import("zig_utils"); +const SparseSet = zig_utils.SparseSet; + +pub const SparseSetSpec = struct { + pub const basic_operations = struct { + test "inserts and retrieves values" { + const allocator = std.testing.allocator; + + var set = try SparseSet(u64, u64).init(allocator, 1000, 16); + defer set.deinit(); + + try set.put(5, 500); + try set.put(10, 1000); + try set.put(3, 300); + + try expect.equal(set.get(5).?, 500); + try expect.equal(set.get(10).?, 1000); + try expect.equal(set.get(3).?, 300); + try expect.toBeTrue(set.get(999) == null); + } + + test "contains returns correct values" { + const allocator = std.testing.allocator; + + var set = try SparseSet(u64, u64).init(allocator, 1000, 16); + defer set.deinit(); + + try set.put(5, 500); + + try expect.toBeTrue(set.contains(5)); + try expect.toBeFalse(set.contains(999)); + } + + test "updates existing values" { + const allocator = std.testing.allocator; + + var set = try SparseSet(u64, u64).init(allocator, 1000, 16); + defer set.deinit(); + + try set.put(5, 500); + try set.put(5, 555); + + try expect.equal(set.get(5).?, 555); + } + + test "removes values correctly" { + const allocator = std.testing.allocator; + + var set = try SparseSet(u64, u64).init(allocator, 1000, 16); + defer set.deinit(); + + try set.put(5, 500); + try set.put(10, 1000); + + set.remove(10); + + try expect.toBeFalse(set.contains(10)); + try expect.equal(set.len(), 1); + } + + test "iterates over values" { + const allocator = std.testing.allocator; + + var set = try SparseSet(u64, u64).init(allocator, 1000, 16); + defer set.deinit(); + + try set.put(5, 555); + try set.put(3, 300); + + var sum: u64 = 0; + for (set.values()) |v| { + sum += v; + } + try expect.equal(sum, 855); + } + }; + + pub const iteration_after_remove = struct { + test "iterates correctly after removing middle element" { + const allocator = std.testing.allocator; + + var set = try SparseSet(u32, u32).init(allocator, 100, 16); + defer set.deinit(); + + try set.put(1, 10); + try set.put(2, 20); + try set.put(3, 30); + try set.put(4, 40); + + set.remove(2); + + var sum: u32 = 0; + for (set.values()) |v| { + sum += v; + } + try expect.equal(sum, 80); + } + }; + + pub const different_key_types = struct { + test "works with u8 keys" { + const allocator = std.testing.allocator; + + var set = try SparseSet(u8, []const u8).init(allocator, 256, 4); + defer set.deinit(); + + try set.put(0, "zero"); + try set.put(255, "max"); + + try expect.toBeTrue(std.mem.eql(u8, set.get(0).?, "zero")); + try expect.toBeTrue(std.mem.eql(u8, set.get(255).?, "max")); + } + + test "works with u16 keys" { + const allocator = std.testing.allocator; + + var set = try SparseSet(u16, f32).init(allocator, 1000, 4); + defer set.deinit(); + + try set.put(500, 3.14); + + try expect.equal(set.get(500).?, 3.14); + } + }; +}; diff --git a/tests/z_index_buckets_test.zig b/tests/z_index_buckets_test.zig new file mode 100644 index 0000000..32626b1 --- /dev/null +++ b/tests/z_index_buckets_test.zig @@ -0,0 +1,112 @@ +const std = @import("std"); +const expect = @import("zspec").expect; +const zig_utils = @import("zig_utils"); +const ZIndexBuckets = zig_utils.ZIndexBuckets; + +pub const ZIndexBucketsSpec = struct { + pub const basic_operations = struct { + test "inserts and counts items" { + const allocator = std.testing.allocator; + + var buckets = ZIndexBuckets(u32, u8).init(allocator); + defer buckets.deinit(); + + try buckets.insert(100, 5); + try buckets.insert(200, 10); + try buckets.insert(300, 5); + + try expect.equal(buckets.count(), 3); + } + + test "iterates in z-order" { + const allocator = std.testing.allocator; + + var buckets = ZIndexBuckets(u32, u8).init(allocator); + defer buckets.deinit(); + + try buckets.insert(100, 5); + try buckets.insert(200, 10); + try buckets.insert(300, 5); + + var iter = buckets.iterator(); + try expect.equal(iter.next().?, 100); + try expect.equal(iter.next().?, 300); + try expect.equal(iter.next().?, 200); + try expect.toBeTrue(iter.next() == null); + } + + test "removes items correctly" { + const allocator = std.testing.allocator; + + var buckets = ZIndexBuckets(u32, u8).init(allocator); + defer buckets.deinit(); + + try buckets.insert(100, 5); + try buckets.insert(200, 10); + + try expect.toBeTrue(buckets.remove(100, 5)); + try expect.equal(buckets.count(), 1); + } + + test "changes z-index" { + const allocator = std.testing.allocator; + + var buckets = ZIndexBuckets(u32, u8).init(allocator); + defer buckets.deinit(); + + try buckets.insert(200, 10); + + try buckets.changeZIndex(200, 10, 0); + + var iter = buckets.iterator(); + try expect.equal(iter.next().?, 200); + } + }; + + pub const struct_items = struct { + test "works with struct items using eql method" { + const allocator = std.testing.allocator; + + const Item = struct { + id: u32, + name: []const u8, + + pub fn eql(self: @This(), other: @This()) bool { + return self.id == other.id; + } + }; + + var buckets = ZIndexBuckets(Item, u8).init(allocator); + defer buckets.deinit(); + + try buckets.insert(.{ .id = 1, .name = "first" }, 10); + try buckets.insert(.{ .id = 2, .name = "second" }, 5); + + try expect.equal(buckets.count(), 2); + + try expect.toBeTrue(buckets.remove(.{ .id = 1, .name = "" }, 10)); + try expect.equal(buckets.count(), 1); + } + }; + + pub const smaller_z_index_type = struct { + test "works with u4 z-index type" { + const allocator = std.testing.allocator; + + var buckets = ZIndexBuckets(u32, u4).init(allocator); + defer buckets.deinit(); + + try buckets.insert(100, 0); + try buckets.insert(200, 15); + try buckets.insert(300, 8); + + try expect.equal(buckets.count(), 3); + + var iter = buckets.iterator(); + try expect.equal(iter.next().?, 100); + try expect.equal(iter.next().?, 300); + try expect.equal(iter.next().?, 200); + try expect.toBeTrue(iter.next() == null); + } + }; +}; diff --git a/tests/zon_coercion_test.zig b/tests/zon_coercion_test.zig new file mode 100644 index 0000000..970e4d4 --- /dev/null +++ b/tests/zon_coercion_test.zig @@ -0,0 +1,70 @@ +const std = @import("std"); +const expect = @import("zspec").expect; +const zig_utils = @import("zig_utils"); +const zon = zig_utils.zon; + +pub const ZonCoercionSpec = struct { + pub const coerceValue = struct { + test "coerces simple struct with all fields" { + const Target = struct { x: i32, y: i32 }; + const result = zon.coerceValue(Target, .{ .x = 10, .y = 20 }); + + try expect.equal(result.x, 10); + try expect.equal(result.y, 20); + } + + test "coerces nested struct" { + const Inner = struct { value: i32 }; + const Outer = struct { inner: Inner, name: []const u8 }; + const result = zon.coerceValue(Outer, .{ .inner = .{ .value = 42 }, .name = "test" }); + + try expect.equal(result.inner.value, 42); + } + + test "coerces union with enum literal" { + const State = union(enum) { idle, running: u32 }; + const result = zon.coerceValue(State, .idle); + + try expect.toBeTrue(result == .idle); + } + + test "coerces union with payload" { + const Shape = union(enum) { circle: struct { radius: f32 }, rect: struct { w: f32, h: f32 } }; + const result = zon.coerceValue(Shape, .{ .circle = .{ .radius = 5.0 } }); + + try expect.equal(result.circle.radius, 5.0); + } + }; + + pub const tupleToSlice = struct { + test "converts tuple to slice" { + const slice = zon.tupleToSlice(i32, .{ 1, 2, 3 }); + + try expect.equal(slice.len, 3); + try expect.equal(slice[0], 1); + try expect.equal(slice[1], 2); + try expect.equal(slice[2], 3); + } + }; + + pub const buildStruct = struct { + test "builds struct with defaults" { + const Config = struct { width: i32 = 800, height: i32 = 600, title: []const u8 }; + const result = zon.buildStruct(Config, .{ .title = "Test" }); + + try expect.equal(result.width, 800); + try expect.equal(result.height, 600); + } + }; + + pub const mergeStructs = struct { + test "merges structs with override values" { + const base = .{ .x = 10, .y = 20, .color = "red" }; + const overrides = .{ .color = "blue" }; + const result = zon.mergeStructs(base, overrides); + + try expect.equal(result.x, 10); + try expect.toBeTrue(std.mem.eql(u8, result.color, "blue")); + } + }; +};