Fix all Clippy warnings across the server codebase (2411 insertions, 1341 deletions). Raise type-complexity-threshold to 750 and too-many-arguments to 12 in .clippy.toml for idiomatic Bevy ECS system signatures. The server now passes `cargo clippy -- --deny warnings` cleanly. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
346 lines
12 KiB
Rust
346 lines
12 KiB
Rust
//! 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<TilePosition>,
|
||
/// All door placements with neighbour tiles pre-computed.
|
||
doors: Vec<DoorPlacement>,
|
||
/// 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<Room> = Vec::new();
|
||
let mut door_placements: Vec<DoorPlacement> = 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<String> = Vec::new();
|
||
let mut seeds_ok = 0u32;
|
||
|
||
for seed in 0..50u64 {
|
||
let map = generate_map(seed);
|
||
|
||
let mut seed_failures: Vec<String> = 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));
|
||
}
|
||
}
|