//! Fuzzy tests for procedurally generated maps (QA #509, QA epic #455). //! //! Tests structural invariants across 50 random seeds. Each seed produces //! a unique map; all 4 invariants must hold for every seed. //! //! Invariants tested: //! 1. Connectivity — all walkable tiles reachable from player start (BFS) //! 2. Entity bounds — all entity positions within map bounds //! 3. Door adjacency — every door has walkable tiles on both sides //! 4. Tile count — walkable tile count meets minimum floor (MIN_WALKABLE_TILES) //! //! Spec reference: D-010 (deterministic simulation, SimRng seeding), D-030 (testability) use rand::Rng; use settled_reach_server::simulation::movement::{TilePosition, WalkabilityMap}; use settled_reach_server::simulation::rng::SimRng; use std::collections::VecDeque; // ── Map generation constants ──────────────────────────────────────────────── const MAP_W: i32 = 50; const MAP_H: i32 = 50; const NUM_ROOMS: usize = 5; /// Minimum walkable tiles the generator must produce per map. /// /// With 5 rooms of 5–10 tiles each plus corridors, a well-generated map /// comfortably exceeds this floor. A count below this flags a degenerate /// layout (e.g., all room-placement attempts rejected on a seed). /// Value chosen as ~2× the guaranteed-minimum fallback room (6×6 = 36 tiles). const MIN_WALKABLE_TILES: usize = 200; // ── Data types ──────────────────────────────────────────────────────────────── struct Room { x: i32, y: i32, w: i32, h: i32, } /// A door placement with its two walkable neighbours (one on each side). struct DoorPlacement { pos: TilePosition, side_a: TilePosition, side_b: TilePosition, } /// A fully generated procedural map ready for invariant checks. struct ProceduralMap { walkability: WalkabilityMap, /// Player start position — guaranteed walkable. player_start: TilePosition, /// All entity positions: player start + door positions. entities: Vec, /// All door placements with neighbour tiles pre-computed. doors: Vec, /// Number of walkable tiles (pre-counted for Invariant 4). walkable_count: usize, } // ── Map generator ───────────────────────────────────────────────────────────── fn generate_map(seed: u64) -> ProceduralMap { let mut rng = SimRng::new(seed).rng; let mut wm = WalkabilityMap::new_blocked(MAP_W, MAP_H, 1); let mut rooms: Vec = Vec::new(); let mut door_placements: Vec = Vec::new(); // ── Room placement (up to 200 attempts for NUM_ROOMS non-overlapping rooms) ── for _ in 0..200 { if rooms.len() >= NUM_ROOMS { break; } let w: i32 = rng.random_range(5_i32..=10); let h: i32 = rng.random_range(5_i32..=10); // Keep 2-tile margin from map edges. let x: i32 = rng.random_range(2_i32..(MAP_W - w - 2)); let y: i32 = rng.random_range(2_i32..(MAP_H - h - 2)); // Reject if overlaps an existing room (1-tile padding). let overlaps = rooms .iter() .any(|r| x < r.x + r.w + 1 && x + w + 1 > r.x && y < r.y + r.h + 1 && y + h + 1 > r.y); if !overlaps { for ry in y..(y + h) { for rx in x..(x + w) { wm.set_walkable(&TilePosition::new(rx, ry, 0), true); } } rooms.push(Room { x, y, w, h }); } } // Guarantee at least one room to ensure a valid player start. if rooms.is_empty() { let x = 5; let y = 5; for ry in y..(y + 6) { for rx in x..(x + 6) { wm.set_walkable(&TilePosition::new(rx, ry, 0), true); } } rooms.push(Room { x, y, w: 6, h: 6 }); } // ── Connect consecutive rooms with L-shaped corridors ───────────────────── for i in 1..rooms.len() { let prev_cx = rooms[i - 1].x + rooms[i - 1].w / 2; let prev_cy = rooms[i - 1].y + rooms[i - 1].h / 2; let curr_cx = rooms[i].x + rooms[i].w / 2; let curr_cy = rooms[i].y + rooms[i].h / 2; // Horizontal segment at prev_cy, from prev_cx to curr_cx. let x_min = prev_cx.min(curr_cx); let x_max = prev_cx.max(curr_cx); for x in x_min..=x_max { wm.set_walkable(&TilePosition::new(x, prev_cy, 0), true); } // Vertical segment at curr_cx, from prev_cy to curr_cy. let y_min = prev_cy.min(curr_cy); let y_max = prev_cy.max(curr_cy); for y in y_min..=y_max { wm.set_walkable(&TilePosition::new(curr_cx, y, 0), true); } // Place a door at the elbow (curr_cx, prev_cy). // Neighbours: (curr_cx-1, prev_cy) and (curr_cx+1, prev_cy). if curr_cx > 0 && curr_cx < MAP_W - 1 { let door_pos = TilePosition::new(curr_cx, prev_cy, 0); let side_a = TilePosition::new(curr_cx - 1, prev_cy, 0); let side_b = TilePosition::new(curr_cx + 1, prev_cy, 0); if wm.can_move_to(&door_pos) && wm.can_move_to(&side_a) && wm.can_move_to(&side_b) { door_placements.push(DoorPlacement { pos: door_pos, side_a, side_b, }); } } } // ── Count walkable tiles ─────────────────────────────────────────────────── let mut walkable_count = 0; for y in 0..MAP_H { for x in 0..MAP_W { if wm.can_move_to(&TilePosition::new(x, y, 0)) { walkable_count += 1; } } } // Player start: centre of first room (always walkable by construction). let player_start = TilePosition::new(rooms[0].x + rooms[0].w / 2, rooms[0].y + rooms[0].h / 2, 0); let mut entities = vec![player_start]; entities.extend(door_placements.iter().map(|d| d.pos)); ProceduralMap { walkability: wm, player_start, entities, doors: door_placements, walkable_count, } } // ── Invariant checks ────────────────────────────────────────────────────────── /// Invariant 1: all walkable tiles reachable from player start via BFS. fn check_connectivity(map: &ProceduralMap, seed: u64) -> Result<(), String> { if !map.walkability.can_move_to(&map.player_start) { return Err(format!( "[seed {seed}] Player start {:?} is not walkable", map.player_start )); } let mut visited = std::collections::BTreeSet::new(); let mut queue = VecDeque::new(); queue.push_back(map.player_start); visited.insert(map.player_start); while let Some(pos) = queue.pop_front() { for neighbor in pos.cardinal_neighbors() { if neighbor.x >= 0 && neighbor.x < MAP_W && neighbor.y >= 0 && neighbor.y < MAP_H && map.walkability.can_move_to(&neighbor) && !visited.contains(&neighbor) { visited.insert(neighbor); queue.push_back(neighbor); } } } if visited.len() != map.walkable_count { Err(format!( "[seed {seed}] Connectivity: {} walkable tiles but only {} reachable from {:?}", map.walkable_count, visited.len(), map.player_start )) } else { Ok(()) } } /// Invariant 2: every entity position is within map bounds. fn check_entity_bounds(map: &ProceduralMap, seed: u64) -> Result<(), String> { for pos in &map.entities { if pos.x < 0 || pos.x >= MAP_W || pos.y < 0 || pos.y >= MAP_H { return Err(format!( "[seed {seed}] Entity at {:?} is outside map bounds ({MAP_W}×{MAP_H})", pos )); } } Ok(()) } /// Invariant 3: every door has at least one walkable tile on each side. fn check_door_adjacency(map: &ProceduralMap, seed: u64) -> Result<(), String> { for door in &map.doors { if !map.walkability.can_move_to(&door.side_a) { return Err(format!( "[seed {seed}] Door at {:?}: side_a {:?} is not walkable", door.pos, door.side_a )); } if !map.walkability.can_move_to(&door.side_b) { return Err(format!( "[seed {seed}] Door at {:?}: side_b {:?} is not walkable", door.pos, door.side_b )); } } Ok(()) } /// Invariant 4: walkable tile count meets the minimum floor. /// /// Catches degenerate maps where the generator failed to carve usable space. /// BSP room carvers don't have a fixed density target — they have natural /// variance from room sizes and corridor lengths. A minimum floor is the /// correct invariant for this generator type. fn check_tile_count(map: &ProceduralMap, seed: u64) -> Result<(), String> { if map.walkable_count < MIN_WALKABLE_TILES { Err(format!( "[seed {seed}] Tile count {}: expected at least {MIN_WALKABLE_TILES} walkable tiles \ (map too sparse — generator may have failed to place rooms)", map.walkable_count, )) } else { Ok(()) } } // ── Main fuzzy test ─────────────────────────────────────────────────────────── /// Runs all 4 structural invariants across 50 deterministic seeds. /// /// Each seed produces a unique procedurally generated map. All 4 invariants /// must hold for every seed. /// /// Acceptance: `cargo test -p server -- fuzzy_map_50_seeds_all_invariants` #[test] fn fuzzy_map_50_seeds_all_invariants() { let mut failures: Vec = Vec::new(); let mut seeds_ok = 0u32; for seed in 0..50u64 { let map = generate_map(seed); let mut seed_failures: Vec = Vec::new(); if let Err(e) = check_connectivity(&map, seed) { seed_failures.push(e); } if let Err(e) = check_entity_bounds(&map, seed) { seed_failures.push(e); } if let Err(e) = check_door_adjacency(&map, seed) { seed_failures.push(e); } if let Err(e) = check_tile_count(&map, seed) { seed_failures.push(e); } if seed_failures.is_empty() { seeds_ok += 1; } else { failures.extend(seed_failures); } } assert!( failures.is_empty(), "{} seed(s) passed, {} invariant violation(s):\n{}", seeds_ok, failures.len(), failures.join("\n") ); } /// Individual connectivity test — all walkable tiles reachable from player start. #[test] fn fuzzy_map_connectivity_holds_across_seeds() { for seed in 0..50u64 { let map = generate_map(seed); check_connectivity(&map, seed).unwrap_or_else(|e| panic!("{}", e)); } } /// Individual bounds test — all entity positions within map bounds. #[test] fn fuzzy_map_entity_bounds_respected_across_seeds() { for seed in 0..50u64 { let map = generate_map(seed); check_entity_bounds(&map, seed).unwrap_or_else(|e| panic!("{}", e)); } } /// Individual door adjacency test — walkable tiles on both sides of every door. #[test] fn fuzzy_map_door_adjacency_holds_across_seeds() { for seed in 0..50u64 { let map = generate_map(seed); check_door_adjacency(&map, seed).unwrap_or_else(|e| panic!("{}", e)); } } /// Individual tile count test — walkable count meets minimum floor across all seeds. #[test] fn fuzzy_map_tile_count_meets_minimum_across_seeds() { for seed in 0..50u64 { let map = generate_map(seed); check_tile_count(&map, seed).unwrap_or_else(|e| panic!("{}", e)); } }