test(simulation): add fuzzy tests for procedural map generation (#509)

50-seed randomized testing against 4 structural invariants:
walkable connectivity (BFS), entity bounds, door adjacency,
and minimum tile count floor. Includes generator module for
test-scoped procedural map creation.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-28 16:34:58 +01:00
co-authored by Claude Opus 4.6
parent b126e1830d
commit 3cd3a998c0
2 changed files with 922 additions and 0 deletions
+349
View File
@@ -0,0 +1,349 @@
//! 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 rand::SeedableRng;
use rand_chacha::ChaCha20Rng;
use settled_reach_server::simulation::movement::{TilePosition, WalkabilityMap};
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 = ChaCha20Rng::seed_from_u64(seed);
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));
}
}