Files
settled-reach/server/tests/fuzzy_map.rs
T
jpmschweitzerandClaude Opus 4.6 aa79dd97e7 fix(simulation): Clippy cleanup and CI enforcement (#635)
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>
2026-03-17 10:33:15 +01:00

346 lines
12 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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 510 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));
}
}