From 3cd3a998c0e935af05a81ff84d0308830add59a5 Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Sat, 28 Feb 2026 16:34:58 +0100 Subject: [PATCH] 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 --- server/src/simulation/generator.rs | 573 +++++++++++++++++++++++++++++ server/tests/fuzzy_map.rs | 349 ++++++++++++++++++ 2 files changed, 922 insertions(+) create mode 100644 server/src/simulation/generator.rs create mode 100644 server/tests/fuzzy_map.rs diff --git a/server/src/simulation/generator.rs b/server/src/simulation/generator.rs new file mode 100644 index 000000000..b067b8298 --- /dev/null +++ b/server/src/simulation/generator.rs @@ -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; +/// 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 (0–3, max 45°). + pub rotation_steps: u8, + /// Street width multiplier (0.75–2.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 (0–3, 0–3). + pub position: (u8, u8), + pub zoning: ZoningType, + /// Which multi-block reservation this block belongs to (if any). + pub reservation: Option, + pub chunk_layout: ChunkLayout, + pub hosted_sites: Vec, + pub era: Era, + pub era_modifications: Vec, + pub era_cause: Option, + /// Build density scalar (0.0 = open/empty, 1.0 = fully built-up). + pub density: f32, + pub landmark: Option, +} + +/// 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, + 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, +} + +/// 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, + pub role_slots: Vec, + pub active_phases: Vec, +} + +/// Triangle assignment within a social site. +#[derive(Serialize, Deserialize, Clone, Debug)] +pub struct TriangleAssignment { + pub template: TriangleTemplate, + pub purposes: Vec, + pub participants: Vec, + /// 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, + pub structural_changes: Vec, + pub placed_objects: Vec, + pub removed_objects: Vec, +} + +/// 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, + /// 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, + /// Vertical connection specs (stairs, elevators, ladders). + pub vertical_corridors: Vec, + pub hosted_sites: Vec, +} + +// --------------------------------------------------------------------------- +// 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, + /// Corridor spines connecting key access points. + pub corridors: Vec, + /// Number of floor levels in this district (count, unsigned — D-110). + pub z_levels: u8, + + // ── Social Structure ────────────────────────────────── + pub social_sites: Vec, + pub access_points: Vec, + + // ── Cultural / World Context ────────────────────────── + pub society_profile: SocietyProfileRef, + pub zone_palette: Vec, + + // ── Boundary System ─────────────────────────────────── + pub boundaries: DistrictBoundaries, + + // ── Validation ──────────────────────────────────────── + /// Guarantee audit result. `None` for Empty districts. + pub guarantee_audit: Option, +} + +// --------------------------------------------------------------------------- +// 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"); + } +} diff --git a/server/tests/fuzzy_map.rs b/server/tests/fuzzy_map.rs new file mode 100644 index 000000000..922bb45a3 --- /dev/null +++ b/server/tests/fuzzy_map.rs @@ -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, + /// 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 = ChaCha20Rng::seed_from_u64(seed); + 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)); + } +}