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
+573
View File
@@ -0,0 +1,573 @@
//! World generator data model — district skeleton and mobile chunk types.
//!
//! Phase 1 generator output: the `DistrictSkeleton` produced by the world generation
//! pipeline. Consumed by Phase 2 (chunk fill) and by the simulation startup path.
//!
//! **D-110 (signed z-levels):** All z-level *position* fields use `i8` (negative values
//! represent basements/sub-levels). Z-level *count* fields use `u8` (always ≥ 1).
//! The distinction:
//! - `base_z: i8` — where the bottom floor starts (can be negative)
//! - `z_levels: u8` — how many floors total (always positive)
//!
//! **D-108 (MobileChunk):** Vessel/vehicle interior chunks attached to mobile entities.
//! Uses the same chunk primitives as static districts in a simpler flat structure
//! (no Phase 1/Phase 2 split, template-stamped interiors).
//!
//! Sources: workshop-outcomes.md, tyre-round4.md
use serde::{Deserialize, Serialize};
// ---------------------------------------------------------------------------
// Concept ID type aliases
// ---------------------------------------------------------------------------
/// Unique district identifier (content-addressable via hash of world position + seed).
pub type DistrictId = u64;
/// Unique multi-block reservation identifier within a district.
pub type ReservationId = u64;
/// Unique social site identifier within a district.
pub type SocialSiteId = u64;
/// Unique role slot identifier within a social site.
pub type RoleSlotId = u64;
// ---------------------------------------------------------------------------
// Stub types for concepts not yet implemented (defined here to allow
// the data model to compile; will be replaced with proper types when
// the respective systems are implemented)
// ---------------------------------------------------------------------------
/// Biome classification for wilderness districts. Stub — full taxonomy deferred.
pub type Biome = String;
/// Water body sub-classification. Stub — full taxonomy deferred.
pub type WaterType = String;
/// Specialized district function (e.g. military, medical, penal). Stub.
pub type SpecializedFunction = String;
/// Cultural / architectural era tag. Stub.
pub type Era = String;
/// Single era modification applied to a block (conversion, overlay, hybrid). Stub.
pub type EraModification = String;
/// Landmark slot description within a block. Stub.
pub type LandmarkSlot = String;
/// Chunk layout specification within a block (how the 2×2 chunks are arranged). Stub.
pub type ChunkLayout = String;
/// Corridor spine connecting district access points. Stub.
pub type CorridorSpine = String;
/// District context — neighboring districts, world position, system. Stub.
pub type DistrictContext = String;
/// Society profile reference (Miri's cultural ingredients). Stub.
pub type SocietyProfileRef = String;
/// Zone definition — base palette + modifiers + zone name. Stub.
pub type ZoneDefinition = String;
/// District boundary system — edge descriptors with neighbors. Stub.
pub type DistrictBoundaries = String;
/// Guarantee audit result — tier-appropriate spatial invariant checks. Stub.
pub type GuaranteeAuditResult = String;
/// District access point (entry/exit to neighboring district). Stub.
pub type AccessPoint = String;
/// Base visual palette for a zone. Stub.
pub type BasePalette = String;
/// Economic modifier on a zone palette. Stub.
pub type EconomicModifier = String;
/// Faction presence modifier on a zone palette. Stub.
pub type FactionModifier = String;
/// Condition modifier on a zone palette (worn, pristine, damaged). Stub.
pub type ConditionModifier = String;
/// Heritage root modifier (Commonwealth cultural grammar layer). Stub.
pub type HeritageRoot = String;
/// Season modifier (affects palette and ambient conditions). Stub.
pub type Season = String;
/// Role slot within a social site template. Stub.
pub type RoleSlot = String;
/// Day phase (morning, afternoon, evening, night, late-night). Stub.
pub type DayPhase = String;
/// Triangle template reference (links to content/templates/). Stub.
pub type TriangleTemplate = String;
/// Raw chunk tile data for generator use (64×64 bool grid, true = walkable). Stub.
pub type GeneratorChunkData = Vec<bool>;
/// Tile type identifier (references visual tile set). Stub.
pub type TileId = String;
/// Entity identifier for mutation causality tracking. Stub.
pub type EntityId = u64;
/// Action identifier for player-caused mutations. Stub.
pub type ActionId = String;
/// Object instance identifier within a chunk. Stub.
pub type ObjectId = u64;
/// Placed object (item, furniture, fixture) within a chunk. Stub.
pub type PlacedObject = String;
// ---------------------------------------------------------------------------
// World and complexity tier enums
// ---------------------------------------------------------------------------
/// Network importance of a world in the galaxy.
/// Determines simulation fidelity budget and NPC complexity ceiling.
///
/// Source: tyre-round4.md §2.1, workshop-outcomes.md
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, PartialOrd, Ord)]
pub enum WorldTier {
/// Background system — minimal simulation, sparse NPCs. Pure environmental.
Peripheral,
/// Standard Commonwealth system — full simulation, complex social sites.
Connected,
/// Major hub — maximum fidelity, multi-faction politics, all triangle types.
Core,
}
/// Generator content budget for a district.
/// Derived from WorldTier + SettingType at Phase 1.
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
pub enum ComplexityTier {
/// Full social architecture. All Tier 1+2 guarantees. 20-80+ NPCs.
Full,
/// Moderate social architecture. Tier 1 + partial Tier 2. 8-20 NPCs.
Moderate,
/// Minimal social architecture. Tier 1 only. 1-8 NPCs.
Minimal,
/// No social architecture. Pure terrain. 0 NPCs. No guarantees.
Empty,
}
/// Physical setting type for a district.
/// Merged from Gestalt's SettingGeometry and Tyre's TerrainType.
#[derive(Serialize, Deserialize, Clone, Debug)]
pub enum SettingType {
Station,
Urban,
Agricultural,
Maritime,
Wilderness { biome: Biome },
Water { water_type: WaterType },
Transitional,
Orbital,
Specialized { function: SpecializedFunction },
}
/// Classification of a district (high-level function).
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
pub enum DistrictType {
LogisticsHub,
Residential,
Commercial,
Industrial,
Administrative,
Entertainment,
MixedUse,
Transit,
Specialized,
}
/// How blocks are placed within the district's 512×512 footprint.
#[derive(Serialize, Deserialize, Clone, Debug)]
pub enum DistrictLayoutMode {
/// Standard Cartesian grid — perpendicular streets.
Grid,
/// Organic placement with per-block offsets and rotations.
Organic {
placements: [[BlockPlacement; 4]; 4],
},
}
/// Zoning classification for a block or floor zone.
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
pub enum ZoningType {
Commercial,
Residential,
Industrial,
Administrative,
Transit,
Recreational,
Restricted,
Mixed,
}
/// Reservation function — what purpose a multi-block reservation serves.
#[derive(Serialize, Deserialize, Clone, Debug)]
pub enum ReservationFunction {
Skyscraper,
Park,
Terminal,
Plaza,
Monument,
IndustrialComplex,
MilitaryBase,
ResearchFacility,
UndergroundComplex,
}
/// Access tier — who is allowed into a zone under normal circumstances.
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
pub enum AccessTier {
/// Anyone can enter.
Public,
/// Requires employment or residence credential.
Credentialed,
/// Requires explicit invitation or escort.
Restricted,
/// Law enforcement / military only.
Secured,
/// Breach access only — normally sealed.
BreachOnly,
}
/// Vertical corridor type (how floors connect in a multi-level reservation).
#[derive(Serialize, Deserialize, Clone, Debug)]
pub enum VerticalCorridorType {
Stairwell,
Elevator,
FreightLift,
AccessHatch,
EmergencyLadder,
}
/// Load state for a z-level within a reservation.
/// Controls lazy loading of floor data during play.
#[derive(Serialize, Deserialize, Clone, Debug)]
pub enum ZLevelLoadState {
/// Floor is fully generated and in memory.
Loaded(GeneratorChunkData),
/// Floor skeleton only — full data not yet generated.
Skeleton(FloorZone),
/// Floor not yet generated (deferred to Phase 2 on first entry).
Ungenerated,
}
/// Cause of a chunk mutation (for save/load audit trail).
#[derive(Serialize, Deserialize, Clone, Debug)]
pub enum MutationCause {
Explosion { radius: u8, source: EntityId },
Fire { spread_from: Option<(u16, u16)> },
Construction { builder: EntityId },
Decay { time_since_maintenance: u32 },
PlayerAction { action: ActionId },
}
/// Type of structural change to a region of chunk tiles.
#[derive(Serialize, Deserialize, Clone, Debug)]
pub enum StructuralChangeType {
WallDestroyed,
FloorCollapsed,
CeilingBreached,
AreaSealed,
WallConstructed,
}
/// Purpose classification for a triangle within a social site.
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
pub enum TrianglePurpose {
Investigation,
Economic,
Social,
Political,
Tactical,
Mundane,
}
/// What exists on the far side of a wall tile (for destructible geometry).
/// Every wall tile in a generated chunk is tagged with one of these.
#[derive(Serialize, Deserialize, Clone, Debug)]
pub enum WallBackside {
/// Another room/corridor exists (tiles already generated).
AdjacentSpace,
/// Solid structural material — 1-2 tiles of fill, then another wall.
StructuralFill,
/// Narrow utility gap (1-3 tiles): pipes, conduits, non-navigable.
ServiceVoid,
/// Edge of chunk — adjacent chunk's boundary tiles on the far side.
ChunkBoundary,
/// Faces outside (hull, exterior wall) — breach has catastrophic consequences.
Exterior,
}
/// Era cause — why a block has the era tag it has.
#[derive(Serialize, Deserialize, Clone, Debug)]
pub enum EraCause {
Original,
CorporateMerger,
EmergencyExtension,
OrganicGrowth,
InstitutionalIncursion,
EconomicDisruption,
CulturalShift,
}
// ---------------------------------------------------------------------------
// Supporting structs
// ---------------------------------------------------------------------------
/// Organic layout placement for a single block.
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct BlockPlacement {
/// Offset from grid-aligned position (±16 sim tiles per axis max).
pub offset: (i16, i16),
/// Rotation in 15° increments (03, max 45°).
pub rotation_steps: u8,
/// Street width multiplier (0.752.0; default 1.0 = 4 visual tiles).
pub street_width_factor: f32,
}
/// Single block within a district's 4×4 block grid.
/// Each block = 128×128 sim tiles = 2×2 chunks.
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct BlockSkeleton {
/// Grid position (03, 03).
pub position: (u8, u8),
pub zoning: ZoningType,
/// Which multi-block reservation this block belongs to (if any).
pub reservation: Option<ReservationId>,
pub chunk_layout: ChunkLayout,
pub hosted_sites: Vec<SocialSiteId>,
pub era: Era,
pub era_modifications: Vec<EraModification>,
pub era_cause: Option<EraCause>,
/// Build density scalar (0.0 = open/empty, 1.0 = fully built-up).
pub density: f32,
pub landmark: Option<LandmarkSlot>,
}
/// Floor zone within a multi-level reservation.
///
/// **D-110:** `z_level` is `i8` — negative values represent basements
/// (e.g. `z_level = -1` for a sub-basement). This differs from `z_levels: u8`
/// on the parent reservation which counts total floors (always ≥ 1).
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct FloorZone {
/// Absolute z-level of this floor. Negative for sub-ground levels (D-110).
pub z_level: i8,
pub zone_type: ZoningType,
pub zone_palette: ZonePalette,
pub access_tier: AccessTier,
}
/// Vertical connection spec within a multi-level reservation.
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct VerticalCorridorSpec {
/// Which blocks this vertical corridor passes through.
pub block_coords: Vec<(u8, u8)>,
/// Which z-bands this corridor connects.
pub z_bands_connected: Vec<u8>,
pub access_tier: AccessTier,
pub corridor_type: VerticalCorridorType,
}
/// Visual palette for a zone — base material + contextual modifiers.
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct ZonePalette {
pub base: BasePalette,
pub modifiers: Vec<PaletteModifier>,
}
/// Contextual modifier applied on top of a base palette.
#[derive(Serialize, Deserialize, Clone, Debug)]
pub enum PaletteModifier {
EconomicFunction(EconomicModifier),
Era(Era),
FactionPresence(FactionModifier),
Condition(ConditionModifier),
Heritage(HeritageRoot),
Season(Season),
}
/// Social site placement within a district.
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct SocialSitePlacement {
pub site_id: SocialSiteId,
/// Which blocks this site spans.
pub blocks: Vec<(u8, u8)>,
pub template_tag: String,
pub access_tier: AccessTier,
pub triangles: Vec<TriangleAssignment>,
pub role_slots: Vec<RoleSlot>,
pub active_phases: Vec<DayPhase>,
}
/// Triangle assignment within a social site.
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct TriangleAssignment {
pub template: TriangleTemplate,
pub purposes: Vec<TrianglePurpose>,
pub participants: Vec<RoleSlotId>,
/// Which block provides the primary staging area (optional for multi-block sites).
pub staging_block: Option<(u8, u8)>,
}
/// Mutations applied to an already-generated chunk.
/// Stored alongside the chunk in the save file.
#[derive(Serialize, Deserialize, Clone, Debug, Default)]
pub struct ChunkMutations {
pub tile_overrides: Vec<TileOverride>,
pub structural_changes: Vec<StructuralChange>,
pub placed_objects: Vec<PlacedObject>,
pub removed_objects: Vec<ObjectId>,
}
/// Single tile override within a chunk mutation.
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct TileOverride {
/// (x, y, z) within the chunk (z is relative to chunk base, always ≥ 0).
pub position: (u16, u16, u8),
pub new_tile: TileId,
pub cause: MutationCause,
}
/// Rectangular structural change within a chunk.
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct StructuralChange {
pub min: (u16, u16, u8),
pub max: (u16, u16, u8),
pub change_type: StructuralChangeType,
}
// ---------------------------------------------------------------------------
// Multi-block reservation
// ---------------------------------------------------------------------------
/// A reservation spanning multiple blocks within a district.
///
/// Used for skyscrapers, parks, transit terminals, plazas, and any structure
/// that requires more than one 128×128 block.
///
/// **D-110:** `base_z: i8` — the lowest floor of this reservation.
/// Negative values represent sub-ground levels (basements, underground complexes).
/// `z_levels: u8` remains unsigned — it counts total floors (always ≥ 1).
///
/// Example: a building with a 3-level basement at ground + 20 floors above:
/// `base_z: -3, z_levels: 24`
/// A deep mine shaft descending 30 floors below ground:
/// `base_z: -30, z_levels: 30`
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct MultiBlockReservation {
/// Which blocks (grid positions) are part of this reservation.
pub blocks: Vec<(u8, u8)>,
pub template_tag: String,
pub function: ReservationFunction,
/// Total number of floors (always ≥ 1, unsigned). D-110.
pub z_levels: u8,
/// Lowest floor's z-level (can be negative for sub-basement). D-110.
pub base_z: i8,
/// Per-floor zone specifications.
pub floor_zones: Vec<FloorZone>,
/// Number of z-bands (logical groupings of floors by zoning).
pub z_band_count: u8,
/// Zone definitions for each z-band.
pub z_band_zones: Vec<ZoneDefinition>,
/// Vertical connection specs (stairs, elevators, ladders).
pub vertical_corridors: Vec<VerticalCorridorSpec>,
pub hosted_sites: Vec<SocialSiteId>,
}
// ---------------------------------------------------------------------------
// District skeleton
// ---------------------------------------------------------------------------
/// Phase 1 generator output for one district.
///
/// Produced by the async background generation pipeline.
/// Consumed by Phase 2 (chunk-by-chunk fill, on demand) and by
/// the production simulation startup path.
///
/// ## Z-level naming convention (D-110)
///
/// - `z_levels: u8` — how many floor levels exist in this district (count, always ≥ 1)
/// - `base_z` does not appear on `DistrictSkeleton` itself — the district's
/// ground level is always 0. Sub-ground structures use [`MultiBlockReservation`]
/// with a negative `base_z` field.
///
/// ## Determinism (D-010)
///
/// All `Vec<_>` fields are stable-ordered at generation time (sorted by a
/// deterministic key). Generation reproduces identical output for the same seed.
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct DistrictSkeleton {
// ── Identity ──────────────────────────────────────────
pub district_id: DistrictId,
/// Deterministic seed (derived from master seed via SeedChain).
pub seed: u64,
pub district_type: DistrictType,
/// World context (system, world, neighboring districts).
pub context: DistrictContext,
// ── Classification ────────────────────────────────────
pub world_tier: WorldTier,
pub complexity: ComplexityTier,
pub setting: SettingType,
pub layout_mode: DistrictLayoutMode,
// ── Spatial Structure ─────────────────────────────────
/// The 4×4 block grid (each block = 128×128 sim tiles = 2×2 chunks).
pub blocks: [[BlockSkeleton; 4]; 4],
/// Multi-block reservations (skyscrapers, parks, terminals, plazas).
pub reservations: Vec<MultiBlockReservation>,
/// Corridor spines connecting key access points.
pub corridors: Vec<CorridorSpine>,
/// Number of floor levels in this district (count, unsigned — D-110).
pub z_levels: u8,
// ── Social Structure ──────────────────────────────────
pub social_sites: Vec<SocialSitePlacement>,
pub access_points: Vec<AccessPoint>,
// ── Cultural / World Context ──────────────────────────
pub society_profile: SocietyProfileRef,
pub zone_palette: Vec<ZoneDefinition>,
// ── Boundary System ───────────────────────────────────
pub boundaries: DistrictBoundaries,
// ── Validation ────────────────────────────────────────
/// Guarantee audit result. `None` for Empty districts.
pub guarantee_audit: Option<GuaranteeAuditResult>,
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
/// D-110: FloorZone.z_level must be i8 (signed) to support negative sub-levels.
#[test]
fn floor_zone_z_level_is_signed() {
let fz = FloorZone {
z_level: -2,
zone_type: ZoningType::Restricted,
zone_palette: ZonePalette {
base: String::new(),
modifiers: vec![],
},
access_tier: AccessTier::BreachOnly,
};
assert_eq!(fz.z_level, -2, "sub-basement z_level must round-trip as i8");
}
/// D-110: MultiBlockReservation.base_z must be i8 (signed) for underground buildings.
#[test]
fn reservation_base_z_is_signed() {
let r = MultiBlockReservation {
blocks: vec![(0, 0)],
template_tag: "deep-mine".into(),
function: ReservationFunction::UndergroundComplex,
z_levels: 30,
base_z: -30,
floor_zones: vec![],
z_band_count: 5,
z_band_zones: vec![],
vertical_corridors: vec![],
hosted_sites: vec![],
};
assert_eq!(r.base_z, -30, "deep mine base_z must be representable as i8");
assert_eq!(r.z_levels, 30u8, "z_levels count must remain u8");
}
/// D-110: z_levels (count) remains u8 on DistrictSkeleton.
#[test]
fn district_skeleton_z_levels_is_unsigned() {
// Verify z_levels is u8 (cannot hold negative value). Static assertion:
// if this compiled with z_levels = 255u8, the type is correct.
fn assert_u8(_: u8) {}
let z_levels: u8 = 4;
assert_u8(z_levels);
assert!(z_levels > 0, "z_levels is always at least 1");
}
}
+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 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 = 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));
}
}