Skip to main content

proof_engine/game/
dungeon.rs

1//! Procedural floor/dungeon generation for the Chaos RPG.
2//!
3//! Provides full dungeon generation using BSP (Binary Space Partition), room
4//! population, enemy scaling, fog of war, minimap rendering, and floor
5//! progression across 100 floors of increasing difficulty and chaos.
6
7use std::collections::HashMap;
8use glam::IVec2;
9
10use crate::procedural::{Rng, DungeonFloor as ProceduralFloor};
11use crate::procedural::dungeon::{
12    IRect, BspSplitter, DungeonGraph, DungeonTheme,
13    Room as ProceduralRoom, Corridor as ProceduralCorridor,
14};
15
16// ══════════════════════════════════════════════════════════════════════════════
17// Floor Biome
18// ══════════════════════════════════════════════════════════════════════════════
19
20/// Biome determines the visual theme, hazards, and ambient feel of a floor.
21#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
22pub enum FloorBiome {
23    Ruins,
24    Crypt,
25    Library,
26    Forge,
27    Garden,
28    Void,
29    Chaos,
30    Abyss,
31    Cathedral,
32    Laboratory,
33}
34
35/// Static properties for a biome.
36#[derive(Debug, Clone)]
37pub struct BiomeProperties {
38    pub wall_char: char,
39    pub floor_char: char,
40    pub accent_color: (u8, u8, u8),
41    pub ambient_light: f32,
42    pub music_vibe: &'static str,
43    pub hazard_type: HazardType,
44    pub flavor_text: &'static str,
45}
46
47/// Types of environmental hazards per biome.
48#[derive(Debug, Clone, Copy, PartialEq, Eq)]
49pub enum HazardType {
50    None,
51    Crumble,
52    Poison,
53    Fire,
54    Ice,
55    Thorns,
56    VoidRift,
57    ChaosBurst,
58    Darkness,
59    Acid,
60}
61
62impl FloorBiome {
63    /// Return the static properties for this biome.
64    pub fn properties(self) -> BiomeProperties {
65        match self {
66            FloorBiome::Ruins => BiomeProperties {
67                wall_char: '#',
68                floor_char: '.',
69                accent_color: (180, 160, 120),
70                ambient_light: 0.6,
71                music_vibe: "melancholy_strings",
72                hazard_type: HazardType::Crumble,
73                flavor_text: "Shattered walls echo with the memory of civilization.",
74            },
75            FloorBiome::Crypt => BiomeProperties {
76                wall_char: '\u{2593}',
77                floor_char: ',',
78                accent_color: (100, 100, 130),
79                ambient_light: 0.3,
80                music_vibe: "somber_choir",
81                hazard_type: HazardType::Poison,
82                flavor_text: "The dead stir in their alcoves, whispering warnings.",
83            },
84            FloorBiome::Library => BiomeProperties {
85                wall_char: '\u{2588}',
86                floor_char: ':',
87                accent_color: (140, 100, 60),
88                ambient_light: 0.5,
89                music_vibe: "quiet_ambient",
90                hazard_type: HazardType::None,
91                flavor_text: "Tomes of forbidden knowledge line the endless shelves.",
92            },
93            FloorBiome::Forge => BiomeProperties {
94                wall_char: '%',
95                floor_char: '=',
96                accent_color: (220, 120, 40),
97                ambient_light: 0.7,
98                music_vibe: "industrial_rhythm",
99                hazard_type: HazardType::Fire,
100                flavor_text: "Molten metal pours from ancient crucibles, still burning.",
101            },
102            FloorBiome::Garden => BiomeProperties {
103                wall_char: '&',
104                floor_char: '"',
105                accent_color: (60, 180, 80),
106                ambient_light: 0.8,
107                music_vibe: "ethereal_wind",
108                hazard_type: HazardType::Thorns,
109                flavor_text: "Overgrown vines conceal both beauty and peril.",
110            },
111            FloorBiome::Void => BiomeProperties {
112                wall_char: '\u{2591}',
113                floor_char: '\u{00B7}',
114                accent_color: (30, 10, 60),
115                ambient_light: 0.15,
116                music_vibe: "deep_drone",
117                hazard_type: HazardType::VoidRift,
118                flavor_text: "Reality thins here. The darkness between worlds seeps in.",
119            },
120            FloorBiome::Chaos => BiomeProperties {
121                wall_char: '?',
122                floor_char: '~',
123                accent_color: (200, 50, 200),
124                ambient_light: 0.4,
125                music_vibe: "discordant_pulse",
126                hazard_type: HazardType::ChaosBurst,
127                flavor_text: "The laws of nature are merely suggestions on this floor.",
128            },
129            FloorBiome::Abyss => BiomeProperties {
130                wall_char: '\u{2592}',
131                floor_char: ' ',
132                accent_color: (15, 5, 15),
133                ambient_light: 0.05,
134                music_vibe: "silence_with_heartbeat",
135                hazard_type: HazardType::Darkness,
136                flavor_text: "An endless expanse of nothing. Even sound fears to travel.",
137            },
138            FloorBiome::Cathedral => BiomeProperties {
139                wall_char: '\u{2502}',
140                floor_char: '+',
141                accent_color: (200, 180, 220),
142                ambient_light: 0.9,
143                music_vibe: "grand_organ",
144                hazard_type: HazardType::None,
145                flavor_text: "Stained glass casts prismatic light across the nave.",
146            },
147            FloorBiome::Laboratory => BiomeProperties {
148                wall_char: '\u{2554}',
149                floor_char: '.',
150                accent_color: (80, 200, 100),
151                ambient_light: 0.6,
152                music_vibe: "electronic_hum",
153                hazard_type: HazardType::Acid,
154                flavor_text: "Bubbling vials and crackling arcs of energy fill the air.",
155            },
156        }
157    }
158
159    /// Convert a DungeonTheme from the procedural module to the closest biome.
160    pub fn from_dungeon_theme(theme: DungeonTheme) -> Self {
161        match theme {
162            DungeonTheme::Cave => FloorBiome::Ruins,
163            DungeonTheme::Cathedral => FloorBiome::Cathedral,
164            DungeonTheme::Laboratory => FloorBiome::Laboratory,
165            DungeonTheme::Temple => FloorBiome::Library,
166            DungeonTheme::Ruins => FloorBiome::Ruins,
167            DungeonTheme::Void => FloorBiome::Void,
168        }
169    }
170
171    /// Get the DungeonTheme most closely matching this biome.
172    pub fn to_dungeon_theme(self) -> DungeonTheme {
173        match self {
174            FloorBiome::Ruins => DungeonTheme::Ruins,
175            FloorBiome::Crypt => DungeonTheme::Cathedral,
176            FloorBiome::Library => DungeonTheme::Temple,
177            FloorBiome::Forge => DungeonTheme::Laboratory,
178            FloorBiome::Garden => DungeonTheme::Cave,
179            FloorBiome::Void => DungeonTheme::Void,
180            FloorBiome::Chaos => DungeonTheme::Void,
181            FloorBiome::Abyss => DungeonTheme::Void,
182            FloorBiome::Cathedral => DungeonTheme::Cathedral,
183            FloorBiome::Laboratory => DungeonTheme::Laboratory,
184        }
185    }
186}
187
188// ══════════════════════════════════════════════════════════════════════════════
189// Room Types
190// ══════════════════════════════════════════════════════════════════════════════
191
192/// Room types in the Chaos RPG dungeon.
193#[derive(Debug, Clone, PartialEq)]
194pub enum RoomType {
195    Normal,
196    Combat,
197    Treasure,
198    Shop,
199    Shrine,
200    Trap,
201    Puzzle,
202    MiniBoss,
203    Boss,
204    ChaosRift,
205    Rest,
206    Secret,
207    Library,
208    Forge,
209}
210
211impl RoomType {
212    /// Weighted random selection of room type for a given floor depth.
213    pub fn random_for_floor(floor: u32, rng: &mut Rng) -> Self {
214        let mut weights: Vec<(RoomType, f32)> = vec![
215            (RoomType::Normal, 30.0),
216            (RoomType::Combat, 25.0),
217            (RoomType::Treasure, 10.0),
218            (RoomType::Trap, 8.0),
219            (RoomType::Rest, 6.0),
220            (RoomType::Shrine, 5.0),
221            (RoomType::Puzzle, 5.0),
222            (RoomType::Shop, 4.0),
223            (RoomType::Library, 3.0),
224            (RoomType::Forge, 2.0),
225            (RoomType::Secret, 1.5),
226            (RoomType::ChaosRift, 0.5),
227        ];
228        // Higher floors increase combat/trap, decrease rest
229        if floor > 25 {
230            for entry in &mut weights {
231                match entry.0 {
232                    RoomType::Combat => entry.1 += 10.0,
233                    RoomType::Trap => entry.1 += 5.0,
234                    RoomType::Rest => entry.1 = (entry.1 - 2.0).max(1.0),
235                    RoomType::ChaosRift => entry.1 += 3.0,
236                    _ => {}
237                }
238            }
239        }
240        if floor > 50 {
241            for entry in &mut weights {
242                match entry.0 {
243                    RoomType::ChaosRift => entry.1 += 5.0,
244                    RoomType::Normal => entry.1 = (entry.1 - 10.0).max(5.0),
245                    _ => {}
246                }
247            }
248        }
249        let total: f32 = weights.iter().map(|(_, w)| *w).sum();
250        let mut r = rng.next_f32() * total;
251        for (rt, w) in &weights {
252            r -= w;
253            if r <= 0.0 {
254                return rt.clone();
255            }
256        }
257        RoomType::Normal
258    }
259}
260
261// ══════════════════════════════════════════════════════════════════════════════
262// Room Shape
263// ══════════════════════════════════════════════════════════════════════════════
264
265/// Shape of the room within its bounding rect.
266#[derive(Debug, Clone, Copy, PartialEq, Eq)]
267pub enum RoomShape {
268    Rectangle,
269    LShaped,
270    Circular,
271    Irregular,
272}
273
274impl RoomShape {
275    /// Pick a room shape based on corruption level.
276    pub fn random(corruption: u32, rng: &mut Rng) -> Self {
277        let irregular_chance = (corruption as f32 / 100.0).min(0.3);
278        let circular_chance = 0.08;
279        let l_shaped_chance = 0.15;
280        let roll = rng.next_f32();
281        if roll < irregular_chance {
282            RoomShape::Irregular
283        } else if roll < irregular_chance + circular_chance {
284            RoomShape::Circular
285        } else if roll < irregular_chance + circular_chance + l_shaped_chance {
286            RoomShape::LShaped
287        } else {
288            RoomShape::Rectangle
289        }
290    }
291
292    /// Carve tiles for this shape within a given rect onto a tile grid.
293    pub fn carve(&self, rect: &IRect, tiles: &mut Vec<Tile>, map_width: usize, rng: &mut Rng) {
294        let x0 = rect.x as usize;
295        let y0 = rect.y as usize;
296        let w = rect.w as usize;
297        let h = rect.h as usize;
298        match self {
299            RoomShape::Rectangle => {
300                for dy in 0..h {
301                    for dx in 0..w {
302                        let idx = (y0 + dy) * map_width + (x0 + dx);
303                        if idx < tiles.len() {
304                            tiles[idx] = Tile::Floor;
305                        }
306                    }
307                }
308            }
309            RoomShape::LShaped => {
310                // Main body
311                let half_w = w / 2;
312                let half_h = h / 2;
313                for dy in 0..h {
314                    for dx in 0..w {
315                        if dx < half_w || dy < half_h {
316                            let idx = (y0 + dy) * map_width + (x0 + dx);
317                            if idx < tiles.len() {
318                                tiles[idx] = Tile::Floor;
319                            }
320                        }
321                    }
322                }
323            }
324            RoomShape::Circular => {
325                let cx = w as f32 / 2.0;
326                let cy = h as f32 / 2.0;
327                let rx = cx - 0.5;
328                let ry = cy - 0.5;
329                for dy in 0..h {
330                    for dx in 0..w {
331                        let fx = dx as f32 - cx + 0.5;
332                        let fy = dy as f32 - cy + 0.5;
333                        if (fx * fx) / (rx * rx) + (fy * fy) / (ry * ry) <= 1.0 {
334                            let idx = (y0 + dy) * map_width + (x0 + dx);
335                            if idx < tiles.len() {
336                                tiles[idx] = Tile::Floor;
337                            }
338                        }
339                    }
340                }
341            }
342            RoomShape::Irregular => {
343                // Cellular automata blob
344                let mut grid = vec![false; w * h];
345                // Seed ~55% filled
346                for cell in grid.iter_mut() {
347                    *cell = rng.next_f32() < 0.55;
348                }
349                // 3 smoothing iterations
350                for _ in 0..3 {
351                    let mut next = grid.clone();
352                    for dy in 0..h {
353                        for dx in 0..w {
354                            let mut alive = 0;
355                            for ny in dy.saturating_sub(1)..=(dy + 1).min(h - 1) {
356                                for nx in dx.saturating_sub(1)..=(dx + 1).min(w - 1) {
357                                    if grid[ny * w + nx] {
358                                        alive += 1;
359                                    }
360                                }
361                            }
362                            next[dy * w + dx] = alive >= 5;
363                        }
364                    }
365                    grid = next;
366                }
367                for dy in 0..h {
368                    for dx in 0..w {
369                        if grid[dy * w + dx] {
370                            let idx = (y0 + dy) * map_width + (x0 + dx);
371                            if idx < tiles.len() {
372                                tiles[idx] = Tile::Floor;
373                            }
374                        }
375                    }
376                }
377            }
378        }
379    }
380}
381
382// ══════════════════════════════════════════════════════════════════════════════
383// Corridor Style
384// ══════════════════════════════════════════════════════════════════════════════
385
386/// How corridors are generated between rooms.
387#[derive(Debug, Clone, Copy, PartialEq, Eq)]
388pub enum CorridorStyle {
389    Straight,
390    Winding,
391    Organic,
392}
393
394impl CorridorStyle {
395    /// Carve a corridor path into the tile grid.
396    pub fn carve_corridor(
397        &self,
398        from: IVec2,
399        to: IVec2,
400        tiles: &mut Vec<Tile>,
401        map_width: usize,
402        map_height: usize,
403        rng: &mut Rng,
404    ) -> Vec<IVec2> {
405        match self {
406            CorridorStyle::Straight => {
407                Self::carve_l_bend(from, to, tiles, map_width, rng)
408            }
409            CorridorStyle::Winding => {
410                Self::carve_winding(from, to, tiles, map_width, map_height, rng)
411            }
412            CorridorStyle::Organic => {
413                Self::carve_organic(from, to, tiles, map_width, map_height, rng)
414            }
415        }
416    }
417
418    fn carve_l_bend(
419        from: IVec2,
420        to: IVec2,
421        tiles: &mut Vec<Tile>,
422        map_width: usize,
423        rng: &mut Rng,
424    ) -> Vec<IVec2> {
425        let mut path = Vec::new();
426        let bend = if rng.chance(0.5) {
427            IVec2::new(to.x, from.y)
428        } else {
429            IVec2::new(from.x, to.y)
430        };
431        // from -> bend
432        let mut cur = from;
433        while cur != bend {
434            Self::set_tile(cur, tiles, map_width, Tile::Corridor);
435            path.push(cur);
436            if cur.x < bend.x { cur.x += 1; }
437            else if cur.x > bend.x { cur.x -= 1; }
438            if cur.y < bend.y { cur.y += 1; }
439            else if cur.y > bend.y { cur.y -= 1; }
440        }
441        // bend -> to
442        while cur != to {
443            Self::set_tile(cur, tiles, map_width, Tile::Corridor);
444            path.push(cur);
445            if cur.x < to.x { cur.x += 1; }
446            else if cur.x > to.x { cur.x -= 1; }
447            if cur.y < to.y { cur.y += 1; }
448            else if cur.y > to.y { cur.y -= 1; }
449        }
450        Self::set_tile(to, tiles, map_width, Tile::Corridor);
451        path.push(to);
452        path
453    }
454
455    fn carve_winding(
456        from: IVec2,
457        to: IVec2,
458        tiles: &mut Vec<Tile>,
459        map_width: usize,
460        map_height: usize,
461        rng: &mut Rng,
462    ) -> Vec<IVec2> {
463        // Perlin-like displacement: walk from->to with random perpendicular jitter
464        let mut path = Vec::new();
465        let dx = to.x - from.x;
466        let dy = to.y - from.y;
467        let steps = (dx.abs() + dy.abs()).max(1) as usize;
468        for i in 0..=steps {
469            let t = i as f32 / steps as f32;
470            let base_x = from.x as f32 + dx as f32 * t;
471            let base_y = from.y as f32 + dy as f32 * t;
472            // Perpendicular displacement using pseudo-Perlin noise
473            let phase = t * std::f32::consts::PI * 3.0 + rng.next_f32() * 0.3;
474            let amplitude = 2.0 + rng.next_f32() * 1.5;
475            let norm_len = ((dx * dx + dy * dy) as f32).sqrt().max(1.0);
476            let perp_x = -(dy as f32) / norm_len;
477            let perp_y = (dx as f32) / norm_len;
478            let disp = phase.sin() * amplitude;
479            let px = (base_x + perp_x * disp).round() as i32;
480            let py = (base_y + perp_y * disp).round() as i32;
481            let clamped = IVec2::new(
482                px.clamp(1, map_width as i32 - 2),
483                py.clamp(1, map_height as i32 - 2),
484            );
485            Self::set_tile(clamped, tiles, map_width, Tile::Corridor);
486            path.push(clamped);
487        }
488        path
489    }
490
491    fn carve_organic(
492        from: IVec2,
493        to: IVec2,
494        tiles: &mut Vec<Tile>,
495        map_width: usize,
496        map_height: usize,
497        rng: &mut Rng,
498    ) -> Vec<IVec2> {
499        // Random walk biased towards target, then cellular automata smoothing
500        let mut path = Vec::new();
501        let mut cur = from;
502        let max_steps = ((to.x - from.x).abs() + (to.y - from.y).abs()) as usize * 3 + 20;
503        for _ in 0..max_steps {
504            Self::set_tile(cur, tiles, map_width, Tile::Corridor);
505            path.push(cur);
506            if cur == to {
507                break;
508            }
509            let dx = (to.x - cur.x).signum();
510            let dy = (to.y - cur.y).signum();
511            // 60% move toward target, 40% random walk
512            if rng.chance(0.6) {
513                if rng.chance(0.5) && dx != 0 {
514                    cur.x += dx;
515                } else if dy != 0 {
516                    cur.y += dy;
517                } else {
518                    cur.x += dx;
519                }
520            } else {
521                match rng.range_usize(4) {
522                    0 => cur.x += 1,
523                    1 => cur.x -= 1,
524                    2 => cur.y += 1,
525                    _ => cur.y -= 1,
526                }
527            }
528            cur.x = cur.x.clamp(1, map_width as i32 - 2);
529            cur.y = cur.y.clamp(1, map_height as i32 - 2);
530        }
531        // Widen the path slightly with adjacent tiles
532        let widen: Vec<IVec2> = path.clone();
533        for p in &widen {
534            for offset in &[IVec2::new(1, 0), IVec2::new(0, 1)] {
535                let adj = *p + *offset;
536                if adj.x > 0
537                    && adj.x < map_width as i32 - 1
538                    && adj.y > 0
539                    && adj.y < map_height as i32 - 1
540                {
541                    if rng.chance(0.3) {
542                        Self::set_tile(adj, tiles, map_width, Tile::Corridor);
543                    }
544                }
545            }
546        }
547        path
548    }
549
550    fn set_tile(pos: IVec2, tiles: &mut [Tile], map_width: usize, tile: Tile) {
551        let idx = pos.y as usize * map_width + pos.x as usize;
552        if idx < tiles.len() && tiles[idx] == Tile::Wall {
553            tiles[idx] = tile;
554        }
555    }
556}
557
558// ══════════════════════════════════════════════════════════════════════════════
559// Tile
560// ══════════════════════════════════════════════════════════════════════════════
561
562/// Extended tile types for the Chaos RPG floor map.
563#[derive(Debug, Clone, Copy, PartialEq, Eq)]
564pub enum Tile {
565    Floor,
566    Wall,
567    Corridor,
568    Door,
569    StairsDown,
570    StairsUp,
571    Trap,
572    Chest,
573    Shrine,
574    ShopCounter,
575    Void,
576    SecretWall,
577    Water,
578    Lava,
579    Ice,
580}
581
582/// Physical properties of a tile.
583#[derive(Debug, Clone)]
584pub struct TileProperties {
585    pub walkable: bool,
586    pub blocks_sight: bool,
587    pub damage_on_step: Option<f32>,
588    pub slow_factor: f32,
589    pub element: Option<Element>,
590}
591
592/// Elemental affinity for tiles and attacks.
593#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
594pub enum Element {
595    Fire,
596    Ice,
597    Poison,
598    Lightning,
599    Void,
600    Chaos,
601    Holy,
602    Dark,
603}
604
605impl Tile {
606    /// Get physical properties for this tile type.
607    pub fn properties(self) -> TileProperties {
608        match self {
609            Tile::Floor => TileProperties {
610                walkable: true, blocks_sight: false,
611                damage_on_step: None, slow_factor: 1.0, element: None,
612            },
613            Tile::Wall => TileProperties {
614                walkable: false, blocks_sight: true,
615                damage_on_step: None, slow_factor: 1.0, element: None,
616            },
617            Tile::Corridor => TileProperties {
618                walkable: true, blocks_sight: false,
619                damage_on_step: None, slow_factor: 1.0, element: None,
620            },
621            Tile::Door => TileProperties {
622                walkable: true, blocks_sight: true,
623                damage_on_step: None, slow_factor: 0.8, element: None,
624            },
625            Tile::StairsDown | Tile::StairsUp => TileProperties {
626                walkable: true, blocks_sight: false,
627                damage_on_step: None, slow_factor: 1.0, element: None,
628            },
629            Tile::Trap => TileProperties {
630                walkable: true, blocks_sight: false,
631                damage_on_step: Some(10.0), slow_factor: 0.5, element: None,
632            },
633            Tile::Chest => TileProperties {
634                walkable: false, blocks_sight: false,
635                damage_on_step: None, slow_factor: 1.0, element: None,
636            },
637            Tile::Shrine => TileProperties {
638                walkable: false, blocks_sight: false,
639                damage_on_step: None, slow_factor: 1.0, element: Some(Element::Holy),
640            },
641            Tile::ShopCounter => TileProperties {
642                walkable: false, blocks_sight: false,
643                damage_on_step: None, slow_factor: 1.0, element: None,
644            },
645            Tile::Void => TileProperties {
646                walkable: false, blocks_sight: true,
647                damage_on_step: Some(999.0), slow_factor: 0.0, element: Some(Element::Void),
648            },
649            Tile::SecretWall => TileProperties {
650                walkable: false, blocks_sight: true,
651                damage_on_step: None, slow_factor: 1.0, element: None,
652            },
653            Tile::Water => TileProperties {
654                walkable: true, blocks_sight: false,
655                damage_on_step: None, slow_factor: 0.5, element: Some(Element::Ice),
656            },
657            Tile::Lava => TileProperties {
658                walkable: true, blocks_sight: false,
659                damage_on_step: Some(25.0), slow_factor: 0.3, element: Some(Element::Fire),
660            },
661            Tile::Ice => TileProperties {
662                walkable: true, blocks_sight: false,
663                damage_on_step: None, slow_factor: 1.5, element: Some(Element::Ice),
664            },
665        }
666    }
667
668    /// Whether entities can walk through this tile.
669    pub fn is_walkable(self) -> bool {
670        self.properties().walkable
671    }
672
673    /// Whether this tile blocks line of sight.
674    pub fn blocks_sight(self) -> bool {
675        self.properties().blocks_sight
676    }
677}
678
679// ══════════════════════════════════════════════════════════════════════════════
680// Floor Config
681// ══════════════════════════════════════════════════════════════════════════════
682
683/// Configuration for generating a single dungeon floor.
684#[derive(Debug, Clone)]
685pub struct FloorConfig {
686    pub floor_number: u32,
687    pub room_count_range: (u32, u32),
688    pub corridor_style: CorridorStyle,
689    pub difficulty_mult: f32,
690    pub biome: FloorBiome,
691    pub corruption_level: u32,
692    pub special_rooms: Vec<RoomType>,
693    pub boss_floor: bool,
694}
695
696impl FloorConfig {
697    /// Generate a config automatically based on floor number.
698    pub fn for_floor(floor_number: u32) -> Self {
699        let theme = FloorTheme::for_floor(floor_number);
700        let boss_floor = floor_number % 10 == 0 || floor_number == 100;
701        let room_min = if boss_floor { 5 } else { 6 + (floor_number / 15).min(6) };
702        let room_max = if boss_floor { 8 } else { 10 + (floor_number / 10).min(10) };
703        let corridor_style = if floor_number < 11 {
704            CorridorStyle::Straight
705        } else if floor_number < 51 {
706            CorridorStyle::Winding
707        } else {
708            CorridorStyle::Organic
709        };
710        let corruption = (floor_number.saturating_sub(25) * 2).min(200);
711        let mut special_rooms = Vec::new();
712        if boss_floor {
713            special_rooms.push(RoomType::Boss);
714        }
715        if floor_number % 5 == 0 {
716            special_rooms.push(RoomType::Shop);
717        }
718        special_rooms.push(RoomType::Rest);
719
720        FloorConfig {
721            floor_number,
722            room_count_range: (room_min, room_max),
723            corridor_style,
724            difficulty_mult: theme.difficulty_mult,
725            biome: theme.biome,
726            corruption_level: corruption,
727            special_rooms,
728            boss_floor,
729        }
730    }
731}
732
733// ══════════════════════════════════════════════════════════════════════════════
734// Floor Theme
735// ══════════════════════════════════════════════════════════════════════════════
736
737/// Defines the feel of each floor range.
738#[derive(Debug, Clone)]
739pub struct FloorTheme {
740    pub biome: FloorBiome,
741    pub difficulty_mult: f32,
742    pub palette: ThemePalette,
743    pub description: &'static str,
744    pub traps_enabled: bool,
745    pub puzzles_enabled: bool,
746    pub min_safe_rooms: u32,
747}
748
749/// Color palette hint for a theme.
750#[derive(Debug, Clone, Copy)]
751pub struct ThemePalette {
752    pub primary: (u8, u8, u8),
753    pub secondary: (u8, u8, u8),
754    pub accent: (u8, u8, u8),
755}
756
757impl FloorTheme {
758    /// Determine the theme for a given floor number.
759    pub fn for_floor(floor: u32) -> Self {
760        match floor {
761            1..=10 => FloorTheme {
762                biome: FloorBiome::Ruins,
763                difficulty_mult: 1.0,
764                palette: ThemePalette {
765                    primary: (180, 140, 100),
766                    secondary: (140, 110, 80),
767                    accent: (220, 180, 120),
768                },
769                description: "The crumbling entrance. Warm torchlight guides the way.",
770                traps_enabled: false,
771                puzzles_enabled: false,
772                min_safe_rooms: 3,
773            },
774            11..=25 => FloorTheme {
775                biome: FloorBiome::Crypt,
776                difficulty_mult: 1.5,
777                palette: ThemePalette {
778                    primary: (100, 100, 140),
779                    secondary: (70, 70, 110),
780                    accent: (150, 130, 180),
781                },
782                description: "Ancient burial grounds. Traps protect the forgotten dead.",
783                traps_enabled: true,
784                puzzles_enabled: false,
785                min_safe_rooms: 2,
786            },
787            26..=50 => FloorTheme {
788                biome: FloorBiome::Forge,
789                difficulty_mult: 2.0,
790                palette: ThemePalette {
791                    primary: (180, 120, 60),
792                    secondary: (60, 160, 80),
793                    accent: (200, 200, 80),
794                },
795                description: "The workshop depths. Puzzles guard ancient knowledge.",
796                traps_enabled: true,
797                puzzles_enabled: true,
798                min_safe_rooms: 2,
799            },
800            51..=75 => FloorTheme {
801                biome: FloorBiome::Void,
802                difficulty_mult: 3.0,
803                palette: ThemePalette {
804                    primary: (40, 20, 80),
805                    secondary: (20, 10, 50),
806                    accent: (180, 60, 200),
807                },
808                description: "Reality fractures. Corruption seeps through every crack.",
809                traps_enabled: true,
810                puzzles_enabled: true,
811                min_safe_rooms: 1,
812            },
813            76..=99 => FloorTheme {
814                biome: FloorBiome::Abyss,
815                difficulty_mult: 4.5,
816                palette: ThemePalette {
817                    primary: (20, 15, 20),
818                    secondary: (10, 5, 10),
819                    accent: (60, 40, 60),
820                },
821                description: "The bottomless dark. Few safe havens remain.",
822                traps_enabled: true,
823                puzzles_enabled: true,
824                min_safe_rooms: 0,
825            },
826            100 => FloorTheme {
827                biome: FloorBiome::Cathedral,
828                difficulty_mult: 6.0,
829                palette: ThemePalette {
830                    primary: (200, 180, 220),
831                    secondary: (160, 140, 180),
832                    accent: (255, 220, 255),
833                },
834                description: "The Cathedral of the Algorithm. The final reckoning.",
835                traps_enabled: false,
836                puzzles_enabled: false,
837                min_safe_rooms: 0,
838            },
839            _ => FloorTheme {
840                biome: FloorBiome::Chaos,
841                difficulty_mult: 5.0 + (floor as f32 - 100.0) * 0.1,
842                palette: ThemePalette {
843                    primary: (200, 50, 200),
844                    secondary: (100, 30, 150),
845                    accent: (255, 100, 255),
846                },
847                description: "Beyond the Algorithm. Pure chaos reigns.",
848                traps_enabled: true,
849                puzzles_enabled: true,
850                min_safe_rooms: 0,
851            },
852        }
853    }
854}
855
856// ══════════════════════════════════════════════════════════════════════════════
857// Floor Map
858// ══════════════════════════════════════════════════════════════════════════════
859
860/// A room on the Chaos RPG floor.
861#[derive(Debug, Clone)]
862pub struct DungeonRoom {
863    pub id: usize,
864    pub rect: IRect,
865    pub room_type: RoomType,
866    pub shape: RoomShape,
867    pub connections: Vec<usize>,
868    pub spawn_points: Vec<IVec2>,
869    pub items: Vec<RoomItem>,
870    pub enemies: Vec<EnemySpawn>,
871    pub visited: bool,
872    pub cleared: bool,
873}
874
875/// An item placed in a room.
876#[derive(Debug, Clone)]
877pub struct RoomItem {
878    pub pos: IVec2,
879    pub kind: RoomItemKind,
880}
881
882/// Types of interactable items in rooms.
883#[derive(Debug, Clone, PartialEq)]
884pub enum RoomItemKind {
885    Chest { trapped: bool, loot_tier: u32 },
886    HealingShrine,
887    BuffShrine { buff_name: String, floors_remaining: u32 },
888    RiskShrine,
889    Merchant { item_count: u32, price_mult: f32 },
890    Campfire,
891    ForgeAnvil,
892    LoreBook { entry_id: u32 },
893    SpellScroll { spell_name: String },
894    PuzzleBlock { target: IVec2 },
895}
896
897/// An enemy to be spawned in a room.
898#[derive(Debug, Clone)]
899pub struct EnemySpawn {
900    pub pos: IVec2,
901    pub stats: ScaledStats,
902    pub name: String,
903    pub element: Option<Element>,
904    pub is_elite: bool,
905    pub abilities: Vec<String>,
906}
907
908/// A corridor in the floor map.
909#[derive(Debug, Clone)]
910pub struct DungeonCorridor {
911    pub from_room: usize,
912    pub to_room: usize,
913    pub path: Vec<IVec2>,
914    pub style: CorridorStyle,
915    pub has_door: bool,
916}
917
918/// The full tile map for a single floor.
919#[derive(Debug, Clone)]
920pub struct FloorMap {
921    pub width: usize,
922    pub height: usize,
923    pub tiles: Vec<Tile>,
924    pub rooms: Vec<DungeonRoom>,
925    pub corridors: Vec<DungeonCorridor>,
926    pub player_start: IVec2,
927    pub exit_point: IVec2,
928    pub biome: FloorBiome,
929    pub floor_number: u32,
930}
931
932impl FloorMap {
933    /// Get the tile at (x, y), or Wall if out of bounds.
934    pub fn get_tile(&self, x: i32, y: i32) -> Tile {
935        if x < 0 || y < 0 || x >= self.width as i32 || y >= self.height as i32 {
936            return Tile::Wall;
937        }
938        self.tiles[y as usize * self.width + x as usize]
939    }
940
941    /// Set the tile at (x, y) if in bounds.
942    pub fn set_tile(&mut self, x: i32, y: i32, tile: Tile) {
943        if x >= 0 && y >= 0 && x < self.width as i32 && y < self.height as i32 {
944            self.tiles[y as usize * self.width + x as usize] = tile;
945        }
946    }
947
948    /// Find which room contains the given position.
949    pub fn room_at(&self, pos: IVec2) -> Option<&DungeonRoom> {
950        self.rooms.iter().find(|r| r.rect.contains(pos.x, pos.y))
951    }
952
953    /// Find which room contains the given position (mutable).
954    pub fn room_at_mut(&mut self, pos: IVec2) -> Option<&mut DungeonRoom> {
955        self.rooms.iter_mut().find(|r| r.rect.contains(pos.x, pos.y))
956    }
957
958    /// Return all walkable neighbor positions of `pos`.
959    pub fn walkable_neighbors(&self, pos: IVec2) -> Vec<IVec2> {
960        let offsets = [
961            IVec2::new(1, 0), IVec2::new(-1, 0),
962            IVec2::new(0, 1), IVec2::new(0, -1),
963        ];
964        offsets
965            .iter()
966            .map(|o| pos + *o)
967            .filter(|p| self.get_tile(p.x, p.y).is_walkable())
968            .collect()
969    }
970
971    /// Total number of tiles.
972    pub fn tile_count(&self) -> usize {
973        self.width * self.height
974    }
975}
976
977// ══════════════════════════════════════════════════════════════════════════════
978// Floor (wraps FloorMap + metadata)
979// ══════════════════════════════════════════════════════════════════════════════
980
981/// A complete dungeon floor with map, fog, and metadata.
982#[derive(Debug, Clone)]
983pub struct Floor {
984    pub map: FloorMap,
985    pub fog: FogOfWar,
986    pub seed: u64,
987    pub config: FloorConfig,
988    pub enemies_alive: u32,
989    pub total_enemies: u32,
990    pub cleared: bool,
991}
992
993impl Floor {
994    /// Check if the floor has been fully cleared of enemies.
995    pub fn check_cleared(&mut self) -> bool {
996        self.enemies_alive = self.map.rooms.iter()
997            .flat_map(|r| r.enemies.iter())
998            .count() as u32;
999        self.cleared = self.enemies_alive == 0;
1000        self.cleared
1001    }
1002}
1003
1004// ══════════════════════════════════════════════════════════════════════════════
1005// Fog of War
1006// ══════════════════════════════════════════════════════════════════════════════
1007
1008/// Visibility state of a single tile.
1009#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1010pub enum Visibility {
1011    Unseen,
1012    Seen,
1013    Visible,
1014}
1015
1016/// Fog of war tracking for a floor.
1017#[derive(Debug, Clone)]
1018pub struct FogOfWar {
1019    pub width: usize,
1020    pub height: usize,
1021    pub visibility: Vec<Visibility>,
1022}
1023
1024impl FogOfWar {
1025    /// Create a fully unseen fog.
1026    pub fn new(width: usize, height: usize) -> Self {
1027        Self {
1028            width,
1029            height,
1030            visibility: vec![Visibility::Unseen; width * height],
1031        }
1032    }
1033
1034    /// Get visibility at a position.
1035    pub fn get(&self, x: i32, y: i32) -> Visibility {
1036        if x < 0 || y < 0 || x >= self.width as i32 || y >= self.height as i32 {
1037            return Visibility::Unseen;
1038        }
1039        self.visibility[y as usize * self.width + x as usize]
1040    }
1041
1042    /// Set visibility at a position.
1043    pub fn set(&mut self, x: i32, y: i32, vis: Visibility) {
1044        if x >= 0 && y >= 0 && x < self.width as i32 && y < self.height as i32 {
1045            self.visibility[y as usize * self.width + x as usize] = vis;
1046        }
1047    }
1048
1049    /// Demote all Visible tiles to Seen (called before recalculating LOS).
1050    pub fn fade_visible(&mut self) {
1051        for v in self.visibility.iter_mut() {
1052            if *v == Visibility::Visible {
1053                *v = Visibility::Seen;
1054            }
1055        }
1056    }
1057
1058    /// Reveal tiles around `center` using raycast-based line of sight.
1059    pub fn reveal_around(&mut self, center: IVec2, radius: i32, floor_map: &FloorMap) {
1060        self.fade_visible();
1061        // Cast rays in all directions
1062        let steps = (radius * 8).max(32);
1063        for i in 0..steps {
1064            let angle = (i as f32 / steps as f32) * std::f32::consts::TAU;
1065            let dx = angle.cos();
1066            let dy = angle.sin();
1067            let mut cx = center.x as f32 + 0.5;
1068            let mut cy = center.y as f32 + 0.5;
1069            for _ in 0..=radius {
1070                let tx = cx as i32;
1071                let ty = cy as i32;
1072                if tx < 0 || ty < 0 || tx >= self.width as i32 || ty >= self.height as i32 {
1073                    break;
1074                }
1075                self.set(tx, ty, Visibility::Visible);
1076                if floor_map.get_tile(tx, ty).blocks_sight() && (tx != center.x || ty != center.y)
1077                {
1078                    break;
1079                }
1080                cx += dx;
1081                cy += dy;
1082            }
1083        }
1084    }
1085
1086    /// Count how many tiles have been seen or are visible.
1087    pub fn explored_count(&self) -> usize {
1088        self.visibility
1089            .iter()
1090            .filter(|v| **v != Visibility::Unseen)
1091            .count()
1092    }
1093
1094    /// Fraction of map explored.
1095    pub fn explored_fraction(&self) -> f32 {
1096        let total = self.visibility.len();
1097        if total == 0 {
1098            return 0.0;
1099        }
1100        self.explored_count() as f32 / total as f32
1101    }
1102}
1103
1104// ══════════════════════════════════════════════════════════════════════════════
1105// Enemy Scaling
1106// ══════════════════════════════════════════════════════════════════════════════
1107
1108/// Base stats for an enemy before scaling.
1109#[derive(Debug, Clone)]
1110pub struct BaseStats {
1111    pub hp: f32,
1112    pub damage: f32,
1113    pub defense: f32,
1114    pub speed: f32,
1115    pub xp_value: u32,
1116}
1117
1118/// Stats after floor/corruption scaling.
1119#[derive(Debug, Clone)]
1120pub struct ScaledStats {
1121    pub hp: f32,
1122    pub damage: f32,
1123    pub defense: f32,
1124    pub speed: f32,
1125    pub xp_value: u32,
1126    pub level: u32,
1127}
1128
1129/// Scales enemy stats based on floor depth and corruption.
1130pub struct EnemyScaler;
1131
1132impl EnemyScaler {
1133    /// Scale base stats for a given floor and corruption level.
1134    ///
1135    /// - HP scales: base * (1.0 + floor * 0.08)
1136    /// - Damage scales: base * (1.0 + floor * 0.05)
1137    /// - Corruption adds +1% per 10 corruption to all stats
1138    /// - Every 10 floors: enemies gain a new ability
1139    /// - Every 25 floors: new enemy types unlock
1140    /// - Floor 50+: elite variants
1141    pub fn scale_enemy(base: &BaseStats, floor: u32, corruption: u32) -> ScaledStats {
1142        let floor_hp_mult = 1.0 + floor as f32 * 0.08;
1143        let floor_dmg_mult = 1.0 + floor as f32 * 0.05;
1144        let floor_def_mult = 1.0 + floor as f32 * 0.03;
1145        let floor_spd_mult = 1.0 + floor as f32 * 0.01;
1146
1147        let corruption_mult = 1.0 + (corruption as f32 / 10.0) * 0.01;
1148
1149        let hp = base.hp * floor_hp_mult * corruption_mult;
1150        let damage = base.damage * floor_dmg_mult * corruption_mult;
1151        let defense = base.defense * floor_def_mult * corruption_mult;
1152        let speed = base.speed * floor_spd_mult * corruption_mult;
1153        let xp_mult = 1.0 + floor as f32 * 0.04;
1154        let xp_value = (base.xp_value as f32 * xp_mult * corruption_mult) as u32;
1155
1156        ScaledStats {
1157            hp,
1158            damage,
1159            defense,
1160            speed,
1161            xp_value,
1162            level: floor,
1163        }
1164    }
1165
1166    /// Determine how many abilities an enemy should have based on floor.
1167    pub fn ability_count(floor: u32) -> u32 {
1168        floor / 10
1169    }
1170
1171    /// Determine if new enemy types are unlocked at this floor.
1172    pub fn new_types_unlocked(floor: u32) -> bool {
1173        floor % 25 == 0 && floor > 0
1174    }
1175
1176    /// Determine the name prefix for elite enemies on higher floors.
1177    pub fn elite_prefix(floor: u32, rng: &mut Rng) -> Option<&'static str> {
1178        if floor < 50 {
1179            return None;
1180        }
1181        let prefixes = ["Corrupted", "Ancient", "Void-touched"];
1182        rng.pick(&prefixes).copied()
1183    }
1184
1185    /// Generate abilities for an enemy at a given floor.
1186    pub fn generate_abilities(floor: u32, rng: &mut Rng) -> Vec<String> {
1187        let count = Self::ability_count(floor) as usize;
1188        let pool = [
1189            "Charge", "Enrage", "Shield Bash", "Poison Strike", "Teleport",
1190            "Summon Minion", "Life Drain", "Fire Breath", "Frost Nova",
1191            "Shadow Step", "Berserk", "Heal Pulse", "Void Bolt", "Chain Lightning",
1192            "Earthquake", "Mirror Image", "Petrify Gaze", "Soul Rend",
1193        ];
1194        let mut abilities = Vec::new();
1195        let mut indices: Vec<usize> = (0..pool.len()).collect();
1196        rng.shuffle(&mut indices);
1197        for &i in indices.iter().take(count.min(pool.len())) {
1198            abilities.push(pool[i].to_string());
1199        }
1200        abilities
1201    }
1202}
1203
1204// ══════════════════════════════════════════════════════════════════════════════
1205// Room Populator
1206// ══════════════════════════════════════════════════════════════════════════════
1207
1208/// Populates rooms with enemies, items, and interactables based on type.
1209pub struct RoomPopulator;
1210
1211impl RoomPopulator {
1212    /// Populate a room based on its type, floor depth, and biome.
1213    pub fn populate(
1214        room: &mut DungeonRoom,
1215        floor: u32,
1216        corruption: u32,
1217        biome: FloorBiome,
1218        rng: &mut Rng,
1219    ) {
1220        match room.room_type {
1221            RoomType::Combat => Self::populate_combat(room, floor, corruption, biome, rng),
1222            RoomType::Treasure => Self::populate_treasure(room, floor, rng),
1223            RoomType::Shop => Self::populate_shop(room, floor, rng),
1224            RoomType::Shrine => Self::populate_shrine(room, rng),
1225            RoomType::Trap => Self::populate_trap(room, floor, rng),
1226            RoomType::Puzzle => Self::populate_puzzle(room, rng),
1227            RoomType::MiniBoss => Self::populate_miniboss(room, floor, corruption, biome, rng),
1228            RoomType::Boss => Self::populate_boss(room, floor, corruption, biome, rng),
1229            RoomType::ChaosRift => Self::populate_chaos_rift(room, floor, corruption, rng),
1230            RoomType::Rest => Self::populate_rest(room, rng),
1231            RoomType::Secret => Self::populate_secret(room, floor, rng),
1232            RoomType::Library => Self::populate_library(room, floor, rng),
1233            RoomType::Forge => Self::populate_forge(room, rng),
1234            RoomType::Normal => Self::populate_normal(room, floor, corruption, biome, rng),
1235        }
1236    }
1237
1238    fn random_pos_in_room(room: &DungeonRoom, rng: &mut Rng) -> IVec2 {
1239        let r = &room.rect;
1240        IVec2::new(
1241            rng.range_i32(r.x + 1, (r.x + r.w - 2).max(r.x + 1)),
1242            rng.range_i32(r.y + 1, (r.y + r.h - 2).max(r.y + 1)),
1243        )
1244    }
1245
1246    fn element_for_biome(biome: FloorBiome, rng: &mut Rng) -> Option<Element> {
1247        let options = match biome {
1248            FloorBiome::Forge => vec![Element::Fire],
1249            FloorBiome::Crypt => vec![Element::Dark, Element::Poison],
1250            FloorBiome::Garden => vec![Element::Poison],
1251            FloorBiome::Void => vec![Element::Void],
1252            FloorBiome::Chaos => vec![Element::Chaos, Element::Void, Element::Fire, Element::Lightning],
1253            FloorBiome::Abyss => vec![Element::Dark, Element::Void],
1254            FloorBiome::Cathedral => vec![Element::Holy, Element::Lightning],
1255            FloorBiome::Laboratory => vec![Element::Lightning, Element::Poison],
1256            FloorBiome::Library => vec![Element::Fire],
1257            FloorBiome::Ruins => return None,
1258        };
1259        if options.is_empty() {
1260            return None;
1261        }
1262        Some(options[rng.range_usize(options.len())])
1263    }
1264
1265    fn make_enemy(
1266        name: &str,
1267        pos: IVec2,
1268        floor: u32,
1269        corruption: u32,
1270        element: Option<Element>,
1271        rng: &mut Rng,
1272    ) -> EnemySpawn {
1273        let base = BaseStats {
1274            hp: 30.0,
1275            damage: 8.0,
1276            defense: 3.0,
1277            speed: 1.0,
1278            xp_value: 10,
1279        };
1280        let stats = EnemyScaler::scale_enemy(&base, floor, corruption);
1281        let is_elite = floor >= 50 && rng.chance(0.2);
1282        let prefix = if is_elite {
1283            EnemyScaler::elite_prefix(floor, rng).unwrap_or("Elite")
1284        } else {
1285            ""
1286        };
1287        let full_name = if is_elite {
1288            format!("{} {}", prefix, name)
1289        } else {
1290            name.to_string()
1291        };
1292        let abilities = EnemyScaler::generate_abilities(floor, rng);
1293        EnemySpawn {
1294            pos,
1295            stats,
1296            name: full_name,
1297            element,
1298            is_elite,
1299            abilities,
1300        }
1301    }
1302
1303    fn populate_combat(
1304        room: &mut DungeonRoom,
1305        floor: u32,
1306        corruption: u32,
1307        biome: FloorBiome,
1308        rng: &mut Rng,
1309    ) {
1310        let count = rng.range_i32(2, 6) as usize;
1311        let element = Self::element_for_biome(biome, rng);
1312        let enemy_names = ["Shade", "Wraith", "Golem", "Serpent", "Husk", "Warden"];
1313        for _ in 0..count {
1314            let pos = Self::random_pos_in_room(room, rng);
1315            let name = enemy_names[rng.range_usize(enemy_names.len())];
1316            room.enemies.push(Self::make_enemy(name, pos, floor, corruption, element, rng));
1317        }
1318    }
1319
1320    fn populate_treasure(room: &mut DungeonRoom, floor: u32, rng: &mut Rng) {
1321        let count = rng.range_i32(1, 3) as usize;
1322        for _ in 0..count {
1323            let pos = Self::random_pos_in_room(room, rng);
1324            let trapped = rng.chance(0.25);
1325            let tier = 1 + floor / 10;
1326            room.items.push(RoomItem {
1327                pos,
1328                kind: RoomItemKind::Chest { trapped, loot_tier: tier },
1329            });
1330        }
1331    }
1332
1333    fn populate_shop(room: &mut DungeonRoom, floor: u32, rng: &mut Rng) {
1334        let center = room.rect.center();
1335        let item_count = rng.range_i32(4, 8) as u32;
1336        let price_mult = 1.0 + floor as f32 * 0.05;
1337        room.items.push(RoomItem {
1338            pos: center,
1339            kind: RoomItemKind::Merchant { item_count, price_mult },
1340        });
1341    }
1342
1343    fn populate_shrine(room: &mut DungeonRoom, rng: &mut Rng) {
1344        let pos = room.rect.center();
1345        let roll = rng.next_f32();
1346        let kind = if roll < 0.4 {
1347            RoomItemKind::HealingShrine
1348        } else if roll < 0.75 {
1349            let buffs = ["Fortitude", "Swiftness", "Might", "Arcane Sight", "Iron Skin"];
1350            let buff_name = buffs[rng.range_usize(buffs.len())].to_string();
1351            RoomItemKind::BuffShrine {
1352                buff_name,
1353                floors_remaining: 5,
1354            }
1355        } else {
1356            RoomItemKind::RiskShrine
1357        };
1358        room.items.push(RoomItem { pos, kind });
1359    }
1360
1361    fn populate_trap(room: &mut DungeonRoom, floor: u32, rng: &mut Rng) {
1362        let count = rng.range_i32(2, 4) as usize;
1363        let trap_names = ["Pendulum", "Spikes", "Arrows", "Flames"];
1364        for _ in 0..count {
1365            let pos = Self::random_pos_in_room(room, rng);
1366            room.spawn_points.push(pos);
1367        }
1368        // Also place a small reward for surviving
1369        if rng.chance(0.5) {
1370            let pos = Self::random_pos_in_room(room, rng);
1371            let tier = 1 + floor / 15;
1372            room.items.push(RoomItem {
1373                pos,
1374                kind: RoomItemKind::Chest { trapped: false, loot_tier: tier },
1375            });
1376        }
1377        let _ = trap_names; // Used by TrapSystem in runtime
1378    }
1379
1380    fn populate_puzzle(room: &mut DungeonRoom, rng: &mut Rng) {
1381        // Place 2-3 puzzle blocks with target positions
1382        let count = rng.range_i32(2, 3) as usize;
1383        for _ in 0..count {
1384            let pos = Self::random_pos_in_room(room, rng);
1385            let target = Self::random_pos_in_room(room, rng);
1386            room.items.push(RoomItem {
1387                pos,
1388                kind: RoomItemKind::PuzzleBlock { target },
1389            });
1390        }
1391    }
1392
1393    fn populate_miniboss(
1394        room: &mut DungeonRoom,
1395        floor: u32,
1396        corruption: u32,
1397        biome: FloorBiome,
1398        rng: &mut Rng,
1399    ) {
1400        let element = Self::element_for_biome(biome, rng);
1401        let pos = room.rect.center();
1402        let base = BaseStats {
1403            hp: 120.0,
1404            damage: 25.0,
1405            defense: 10.0,
1406            speed: 0.8,
1407            xp_value: 80,
1408        };
1409        let stats = EnemyScaler::scale_enemy(&base, floor, corruption);
1410        let abilities = EnemyScaler::generate_abilities(floor, rng);
1411        let boss_names = ["Guardian", "Sentinel", "Revenant", "Behemoth", "Archon"];
1412        let name = boss_names[rng.range_usize(boss_names.len())].to_string();
1413        room.enemies.push(EnemySpawn {
1414            pos,
1415            stats,
1416            name,
1417            element,
1418            is_elite: true,
1419            abilities,
1420        });
1421    }
1422
1423    fn populate_boss(
1424        room: &mut DungeonRoom,
1425        floor: u32,
1426        corruption: u32,
1427        biome: FloorBiome,
1428        rng: &mut Rng,
1429    ) {
1430        let element = Self::element_for_biome(biome, rng);
1431        let pos = room.rect.center();
1432        let base = BaseStats {
1433            hp: 500.0,
1434            damage: 50.0,
1435            defense: 25.0,
1436            speed: 0.6,
1437            xp_value: 500,
1438        };
1439        let stats = EnemyScaler::scale_enemy(&base, floor, corruption);
1440        let mut abilities = EnemyScaler::generate_abilities(floor, rng);
1441        // Bosses always have at least 3 abilities
1442        let extra = ["Phase Shift", "Devastating Slam", "Summon Elites"];
1443        for a in &extra {
1444            if abilities.len() < 3 {
1445                abilities.push(a.to_string());
1446            }
1447        }
1448        let boss_name = if floor == 100 {
1449            "The Algorithm Reborn".to_string()
1450        } else {
1451            let titles = [
1452                "The Hollow King", "Archlich Verath", "Ironclad Titan",
1453                "The Void Weaver", "Chaos Incarnate", "The Silent Dread",
1454            ];
1455            titles[rng.range_usize(titles.len())].to_string()
1456        };
1457        room.enemies.push(EnemySpawn {
1458            pos,
1459            stats,
1460            name: boss_name,
1461            element,
1462            is_elite: true,
1463            abilities,
1464        });
1465    }
1466
1467    fn populate_chaos_rift(
1468        room: &mut DungeonRoom,
1469        floor: u32,
1470        corruption: u32,
1471        rng: &mut Rng,
1472    ) {
1473        // Escalating waves: start with 1 enemy, can scale
1474        let initial_count = rng.range_i32(1, 3) as usize;
1475        for _ in 0..initial_count {
1476            let pos = Self::random_pos_in_room(room, rng);
1477            let names = ["Rift Spawn", "Chaos Wisp", "Void Tendril"];
1478            let name = names[rng.range_usize(names.len())];
1479            room.enemies.push(Self::make_enemy(name, pos, floor, corruption, Some(Element::Chaos), rng));
1480        }
1481    }
1482
1483    fn populate_rest(room: &mut DungeonRoom, rng: &mut Rng) {
1484        let pos = room.rect.center();
1485        room.items.push(RoomItem {
1486            pos,
1487            kind: RoomItemKind::Campfire,
1488        });
1489        // Sometimes a small heal shrine too
1490        if rng.chance(0.3) {
1491            let pos2 = Self::random_pos_in_room(room, rng);
1492            room.items.push(RoomItem {
1493                pos: pos2,
1494                kind: RoomItemKind::HealingShrine,
1495            });
1496        }
1497    }
1498
1499    fn populate_secret(room: &mut DungeonRoom, floor: u32, rng: &mut Rng) {
1500        // Always rare loot
1501        let pos = room.rect.center();
1502        let tier = 3 + floor / 10;
1503        room.items.push(RoomItem {
1504            pos,
1505            kind: RoomItemKind::Chest { trapped: false, loot_tier: tier },
1506        });
1507        // Bonus spell scroll
1508        if rng.chance(0.5) {
1509            let pos2 = Self::random_pos_in_room(room, rng);
1510            let spells = ["Meteor", "Time Stop", "Mass Heal", "Void Gate", "Chain Bolt"];
1511            room.items.push(RoomItem {
1512                pos: pos2,
1513                kind: RoomItemKind::SpellScroll {
1514                    spell_name: spells[rng.range_usize(spells.len())].to_string(),
1515                },
1516            });
1517        }
1518    }
1519
1520    fn populate_library(room: &mut DungeonRoom, floor: u32, rng: &mut Rng) {
1521        let book_count = rng.range_i32(1, 3) as usize;
1522        for i in 0..book_count {
1523            let pos = Self::random_pos_in_room(room, rng);
1524            room.items.push(RoomItem {
1525                pos,
1526                kind: RoomItemKind::LoreBook { entry_id: floor * 10 + i as u32 },
1527            });
1528        }
1529        // Spell scroll chance
1530        if rng.chance(0.4) {
1531            let pos = Self::random_pos_in_room(room, rng);
1532            let spells = ["Fireball", "Frost Shield", "Lightning Arc", "Shadow Cloak"];
1533            room.items.push(RoomItem {
1534                pos,
1535                kind: RoomItemKind::SpellScroll {
1536                    spell_name: spells[rng.range_usize(spells.len())].to_string(),
1537                },
1538            });
1539        }
1540    }
1541
1542    fn populate_forge(room: &mut DungeonRoom, rng: &mut Rng) {
1543        let pos = room.rect.center();
1544        room.items.push(RoomItem {
1545            pos,
1546            kind: RoomItemKind::ForgeAnvil,
1547        });
1548    }
1549
1550    fn populate_normal(
1551        room: &mut DungeonRoom,
1552        floor: u32,
1553        corruption: u32,
1554        biome: FloorBiome,
1555        rng: &mut Rng,
1556    ) {
1557        // Small chance of a wandering enemy
1558        if rng.chance(0.3) {
1559            let pos = Self::random_pos_in_room(room, rng);
1560            let element = Self::element_for_biome(biome, rng);
1561            room.enemies.push(Self::make_enemy("Wanderer", pos, floor, corruption, element, rng));
1562        }
1563    }
1564}
1565
1566// ══════════════════════════════════════════════════════════════════════════════
1567// Floor Generator (BSP-based)
1568// ══════════════════════════════════════════════════════════════════════════════
1569
1570/// Generates complete dungeon floors using BSP subdivision.
1571pub struct FloorGenerator;
1572
1573impl FloorGenerator {
1574    /// Generate a complete floor from config and seed.
1575    pub fn generate(config: &FloorConfig, seed: u64) -> Floor {
1576        let mut rng = Rng::new(seed ^ (config.floor_number as u64).wrapping_mul(0xCAFEBABE));
1577
1578        // Determine map dimensions based on floor
1579        let base_w = 60 + (config.floor_number as usize * 4).min(140);
1580        let base_h = 40 + (config.floor_number as usize * 3).min(80);
1581        let map_width = base_w.min(200);
1582        let map_height = base_h.min(120);
1583
1584        // Initialize tile grid
1585        let mut tiles = vec![Tile::Wall; map_width * map_height];
1586
1587        // BSP split to get room rects
1588        let min_room = 7;
1589        let max_depth = 4 + config.floor_number / 15;
1590        let bsp = BspSplitter::new(min_room, 0.2, max_depth.min(8));
1591        let graph = bsp.generate(map_width as i32, map_height as i32, &mut rng);
1592
1593        // Convert procedural rooms to dungeon rooms and carve
1594        let mut rooms: Vec<DungeonRoom> = Vec::new();
1595        for (i, proc_room) in graph.rooms.iter().enumerate() {
1596            // Clamp room size to max 15x15
1597            let mut rect = proc_room.rect;
1598            if rect.w > 15 { rect.w = 15; }
1599            if rect.h > 15 { rect.h = 15; }
1600
1601            let shape = RoomShape::random(config.corruption_level, &mut rng);
1602            shape.carve(&rect, &mut tiles, map_width, &mut rng);
1603
1604            let room_type = Self::assign_room_type(i, graph.rooms.len(), config, &mut rng);
1605            let mut dungeon_room = DungeonRoom {
1606                id: i,
1607                rect,
1608                room_type,
1609                shape,
1610                connections: proc_room.connections.clone(),
1611                spawn_points: proc_room.spawns.clone(),
1612                items: Vec::new(),
1613                enemies: Vec::new(),
1614                visited: false,
1615                cleared: false,
1616            };
1617
1618            // Populate room contents
1619            RoomPopulator::populate(
1620                &mut dungeon_room,
1621                config.floor_number,
1622                config.corruption_level,
1623                config.biome,
1624                &mut rng,
1625            );
1626            rooms.push(dungeon_room);
1627        }
1628
1629        // Carve corridors
1630        let mut corridors: Vec<DungeonCorridor> = Vec::new();
1631        for proc_corr in &graph.corridors {
1632            let from_center = if proc_corr.from < rooms.len() {
1633                rooms[proc_corr.from].rect.center()
1634            } else {
1635                IVec2::ZERO
1636            };
1637            let to_center = if proc_corr.to < rooms.len() {
1638                rooms[proc_corr.to].rect.center()
1639            } else {
1640                IVec2::ZERO
1641            };
1642            let path = config.corridor_style.carve_corridor(
1643                from_center,
1644                to_center,
1645                &mut tiles,
1646                map_width,
1647                map_height,
1648                &mut rng,
1649            );
1650            corridors.push(DungeonCorridor {
1651                from_room: proc_corr.from,
1652                to_room: proc_corr.to,
1653                path,
1654                style: config.corridor_style,
1655                has_door: proc_corr.has_door,
1656            });
1657        }
1658
1659        // Place doors at corridor-room junctions
1660        for corr in &corridors {
1661            if corr.has_door {
1662                if let Some(first) = corr.path.first() {
1663                    let idx = first.y as usize * map_width + first.x as usize;
1664                    if idx < tiles.len() && tiles[idx] == Tile::Corridor {
1665                        tiles[idx] = Tile::Door;
1666                    }
1667                }
1668                if let Some(last) = corr.path.last() {
1669                    let idx = last.y as usize * map_width + last.x as usize;
1670                    if idx < tiles.len() && tiles[idx] == Tile::Corridor {
1671                        tiles[idx] = Tile::Door;
1672                    }
1673                }
1674            }
1675        }
1676
1677        // Place stairs
1678        let player_start = if !rooms.is_empty() {
1679            rooms[0].rect.center()
1680        } else {
1681            IVec2::new(map_width as i32 / 2, map_height as i32 / 2)
1682        };
1683        let exit_point = if rooms.len() > 1 {
1684            rooms[rooms.len() - 1].rect.center()
1685        } else {
1686            player_start
1687        };
1688
1689        // Mark stairs tiles
1690        {
1691            let idx = player_start.y as usize * map_width + player_start.x as usize;
1692            if idx < tiles.len() {
1693                tiles[idx] = Tile::StairsUp;
1694            }
1695        }
1696        {
1697            let idx = exit_point.y as usize * map_width + exit_point.x as usize;
1698            if idx < tiles.len() {
1699                tiles[idx] = Tile::StairsDown;
1700            }
1701        }
1702
1703        // Place special tiles from items
1704        for room in &rooms {
1705            for item in &room.items {
1706                let idx = item.pos.y as usize * map_width + item.pos.x as usize;
1707                if idx < tiles.len() {
1708                    match &item.kind {
1709                        RoomItemKind::Chest { .. } => tiles[idx] = Tile::Chest,
1710                        RoomItemKind::HealingShrine
1711                        | RoomItemKind::BuffShrine { .. }
1712                        | RoomItemKind::RiskShrine => tiles[idx] = Tile::Shrine,
1713                        RoomItemKind::Merchant { .. } => tiles[idx] = Tile::ShopCounter,
1714                        _ => {}
1715                    }
1716                }
1717            }
1718        }
1719
1720        // Place secret walls for secret rooms
1721        for room in &rooms {
1722            if room.room_type == RoomType::Secret {
1723                // Replace one wall adjacent to the room with a SecretWall
1724                let candidates = [
1725                    IVec2::new(room.rect.x - 1, room.rect.y + room.rect.h / 2),
1726                    IVec2::new(room.rect.x + room.rect.w, room.rect.y + room.rect.h / 2),
1727                    IVec2::new(room.rect.x + room.rect.w / 2, room.rect.y - 1),
1728                    IVec2::new(room.rect.x + room.rect.w / 2, room.rect.y + room.rect.h),
1729                ];
1730                for c in &candidates {
1731                    let ci = c.y as usize * map_width + c.x as usize;
1732                    if ci < tiles.len() && tiles[ci] == Tile::Wall {
1733                        tiles[ci] = Tile::SecretWall;
1734                        break;
1735                    }
1736                }
1737            }
1738        }
1739
1740        // Add biome hazard tiles
1741        Self::place_hazard_tiles(config, &mut tiles, map_width, map_height, &mut rng);
1742
1743        let total_enemies = rooms.iter().map(|r| r.enemies.len() as u32).sum();
1744
1745        let floor_map = FloorMap {
1746            width: map_width,
1747            height: map_height,
1748            tiles,
1749            rooms,
1750            corridors,
1751            player_start,
1752            exit_point,
1753            biome: config.biome,
1754            floor_number: config.floor_number,
1755        };
1756
1757        let fog = FogOfWar::new(map_width, map_height);
1758
1759        Floor {
1760            map: floor_map,
1761            fog,
1762            seed,
1763            config: config.clone(),
1764            enemies_alive: total_enemies,
1765            total_enemies,
1766            cleared: total_enemies == 0,
1767        }
1768    }
1769
1770    /// Assign room type: first room is always Rest, last is exit-related,
1771    /// boss floors get a boss room, and specials from config are placed.
1772    fn assign_room_type(
1773        index: usize,
1774        total: usize,
1775        config: &FloorConfig,
1776        rng: &mut Rng,
1777    ) -> RoomType {
1778        if index == 0 {
1779            return RoomType::Rest; // Safe start
1780        }
1781        if index == total - 1 && config.boss_floor {
1782            return RoomType::Boss;
1783        }
1784        if index == total - 1 {
1785            return RoomType::Normal;
1786        }
1787        // Place special rooms from config
1788        let special_index = index.saturating_sub(1);
1789        if special_index < config.special_rooms.len() {
1790            return config.special_rooms[special_index].clone();
1791        }
1792        // Random assignment
1793        RoomType::random_for_floor(config.floor_number, rng)
1794    }
1795
1796    /// Place hazard tiles (water, lava, ice) based on biome.
1797    fn place_hazard_tiles(
1798        config: &FloorConfig,
1799        tiles: &mut Vec<Tile>,
1800        width: usize,
1801        height: usize,
1802        rng: &mut Rng,
1803    ) {
1804        let props = config.biome.properties();
1805        let hazard_tile = match props.hazard_type {
1806            HazardType::Fire => Some(Tile::Lava),
1807            HazardType::Ice => Some(Tile::Ice),
1808            HazardType::Acid | HazardType::Poison => Some(Tile::Water),
1809            HazardType::VoidRift | HazardType::Darkness => Some(Tile::Void),
1810            _ => None,
1811        };
1812        if let Some(ht) = hazard_tile {
1813            let count = rng.range_i32(3, 8 + config.floor_number as i32 / 5) as usize;
1814            for _ in 0..count {
1815                let x = rng.range_i32(2, width as i32 - 3);
1816                let y = rng.range_i32(2, height as i32 - 3);
1817                let idx = y as usize * width + x as usize;
1818                if idx < tiles.len() && tiles[idx] == Tile::Floor {
1819                    tiles[idx] = ht;
1820                }
1821            }
1822        }
1823    }
1824}
1825
1826// ══════════════════════════════════════════════════════════════════════════════
1827// Minimap
1828// ══════════════════════════════════════════════════════════════════════════════
1829
1830/// A glyph for the minimap display.
1831#[derive(Debug, Clone)]
1832pub struct MinimapGlyph {
1833    pub x: i32,
1834    pub y: i32,
1835    pub ch: char,
1836    pub color: (u8, u8, u8),
1837}
1838
1839/// Renders a compact minimap of the floor.
1840pub struct Minimap;
1841
1842impl Minimap {
1843    /// Render the minimap given the floor map, player position, and fog.
1844    /// Returns a list of glyphs that represent the minimap at reduced scale.
1845    pub fn render_minimap(
1846        floor: &FloorMap,
1847        player_pos: IVec2,
1848        fog: &FogOfWar,
1849    ) -> Vec<MinimapGlyph> {
1850        let scale = 4; // Each minimap cell = 4x4 tiles
1851        let mw = (floor.width + scale - 1) / scale;
1852        let mh = (floor.height + scale - 1) / scale;
1853        let biome_props = floor.biome.properties();
1854
1855        let mut glyphs = Vec::with_capacity(mw * mh);
1856
1857        for my in 0..mh {
1858            for mx in 0..mw {
1859                let tx = (mx * scale) as i32;
1860                let ty = (my * scale) as i32;
1861
1862                // Sample the dominant tile in this minimap cell
1863                let mut floor_count = 0u32;
1864                let mut wall_count = 0u32;
1865                let mut special = false;
1866                let mut any_visible = false;
1867
1868                for dy in 0..scale as i32 {
1869                    for dx in 0..scale as i32 {
1870                        let sx = tx + dx;
1871                        let sy = ty + dy;
1872                        let vis = fog.get(sx, sy);
1873                        if vis == Visibility::Unseen {
1874                            continue;
1875                        }
1876                        any_visible = true;
1877                        let tile = floor.get_tile(sx, sy);
1878                        match tile {
1879                            Tile::Floor | Tile::Corridor => floor_count += 1,
1880                            Tile::Wall | Tile::Void => wall_count += 1,
1881                            _ => {
1882                                special = true;
1883                                floor_count += 1;
1884                            }
1885                        }
1886                    }
1887                }
1888
1889                if !any_visible {
1890                    continue; // Skip unseen areas
1891                }
1892
1893                let (ch, color) = if special {
1894                    ('!', (255, 255, 100))
1895                } else if floor_count > wall_count {
1896                    ('.', biome_props.accent_color)
1897                } else {
1898                    ('#', (80, 80, 80))
1899                };
1900
1901                glyphs.push(MinimapGlyph {
1902                    x: mx as i32,
1903                    y: my as i32,
1904                    ch,
1905                    color,
1906                });
1907            }
1908        }
1909
1910        // Player position marker
1911        let px = player_pos.x as usize / scale;
1912        let py = player_pos.y as usize / scale;
1913        glyphs.push(MinimapGlyph {
1914            x: px as i32,
1915            y: py as i32,
1916            ch: '@',
1917            color: (255, 255, 255),
1918        });
1919
1920        // Exit marker (if seen)
1921        let ex = floor.exit_point.x;
1922        let ey = floor.exit_point.y;
1923        if fog.get(ex, ey) != Visibility::Unseen {
1924            glyphs.push(MinimapGlyph {
1925                x: ex as i32 / scale as i32,
1926                y: ey as i32 / scale as i32,
1927                ch: '>',
1928                color: (100, 255, 100),
1929            });
1930        }
1931
1932        glyphs
1933    }
1934}
1935
1936// ══════════════════════════════════════════════════════════════════════════════
1937// Dungeon Manager
1938// ══════════════════════════════════════════════════════════════════════════════
1939
1940/// Top-level manager for dungeon exploration across multiple floors.
1941pub struct DungeonManager {
1942    seed: u64,
1943    current_floor_number: u32,
1944    current_floor: Option<Floor>,
1945    floor_history: HashMap<u32, Floor>,
1946    max_floor_reached: u32,
1947}
1948
1949impl DungeonManager {
1950    /// Create a new dungeon manager with a given seed.
1951    pub fn new(seed: u64) -> Self {
1952        Self {
1953            seed,
1954            current_floor_number: 0,
1955            current_floor: None,
1956            floor_history: HashMap::new(),
1957            max_floor_reached: 0,
1958        }
1959    }
1960
1961    /// Start the dungeon run, generating floor 1.
1962    pub fn start(&mut self) {
1963        self.current_floor_number = 1;
1964        let config = FloorConfig::for_floor(1);
1965        let floor = FloorGenerator::generate(&config, self.seed);
1966        self.current_floor = Some(floor);
1967        self.max_floor_reached = 1;
1968    }
1969
1970    /// Descend to the next floor.
1971    pub fn descend(&mut self) {
1972        // Store current floor in history
1973        if let Some(floor) = self.current_floor.take() {
1974            self.floor_history.insert(self.current_floor_number, floor);
1975        }
1976        self.current_floor_number += 1;
1977
1978        // Check history first (for backtracking)
1979        if let Some(existing) = self.floor_history.remove(&self.current_floor_number) {
1980            self.current_floor = Some(existing);
1981        } else {
1982            let config = FloorConfig::for_floor(self.current_floor_number);
1983            let floor_seed = self.seed.wrapping_add(self.current_floor_number as u64 * 0x517cc1b727220a95);
1984            let floor = FloorGenerator::generate(&config, floor_seed);
1985            self.current_floor = Some(floor);
1986        }
1987
1988        if self.current_floor_number > self.max_floor_reached {
1989            self.max_floor_reached = self.current_floor_number;
1990        }
1991    }
1992
1993    /// Ascend to the previous floor.
1994    pub fn ascend(&mut self) {
1995        if self.current_floor_number <= 1 {
1996            return;
1997        }
1998        if let Some(floor) = self.current_floor.take() {
1999            self.floor_history.insert(self.current_floor_number, floor);
2000        }
2001        self.current_floor_number -= 1;
2002        if let Some(existing) = self.floor_history.remove(&self.current_floor_number) {
2003            self.current_floor = Some(existing);
2004        }
2005    }
2006
2007    /// Get a reference to the current floor.
2008    pub fn current_floor(&self) -> Option<&Floor> {
2009        self.current_floor.as_ref()
2010    }
2011
2012    /// Get a mutable reference to the current floor.
2013    pub fn current_floor_mut(&mut self) -> Option<&mut Floor> {
2014        self.current_floor.as_mut()
2015    }
2016
2017    /// Get the room at a given position on the current floor.
2018    pub fn get_room_at(&self, pos: IVec2) -> Option<&DungeonRoom> {
2019        self.current_floor.as_ref().and_then(|f| f.map.room_at(pos))
2020    }
2021
2022    /// Reveal tiles around a position on the current floor.
2023    pub fn reveal_around(&mut self, pos: IVec2, radius: i32) {
2024        if let Some(floor) = &mut self.current_floor {
2025            floor.fog.reveal_around(pos, radius, &floor.map);
2026        }
2027    }
2028
2029    /// Get the current floor number.
2030    pub fn floor_number(&self) -> u32 {
2031        self.current_floor_number
2032    }
2033
2034    /// Get the maximum floor reached.
2035    pub fn max_floor(&self) -> u32 {
2036        self.max_floor_reached
2037    }
2038
2039    /// Get the seed for this dungeon run.
2040    pub fn seed(&self) -> u64 {
2041        self.seed
2042    }
2043
2044    /// How many floors are stored in history.
2045    pub fn history_size(&self) -> usize {
2046        self.floor_history.len()
2047    }
2048}
2049
2050// ══════════════════════════════════════════════════════════════════════════════
2051// Tests
2052// ══════════════════════════════════════════════════════════════════════════════
2053
2054#[cfg(test)]
2055mod tests {
2056    use super::*;
2057
2058    #[test]
2059    fn test_bsp_generation_produces_rooms() {
2060        let config = FloorConfig::for_floor(1);
2061        let floor = FloorGenerator::generate(&config, 42);
2062        assert!(!floor.map.rooms.is_empty(), "BSP should generate at least one room");
2063        assert!(floor.map.rooms.len() >= 2, "Floor should have at least 2 rooms");
2064    }
2065
2066    #[test]
2067    fn test_bsp_generation_deterministic() {
2068        let config = FloorConfig::for_floor(5);
2069        let floor_a = FloorGenerator::generate(&config, 12345);
2070        let floor_b = FloorGenerator::generate(&config, 12345);
2071        assert_eq!(floor_a.map.rooms.len(), floor_b.map.rooms.len());
2072        assert_eq!(floor_a.map.tiles, floor_b.map.tiles);
2073    }
2074
2075    #[test]
2076    fn test_floor_has_start_and_exit() {
2077        let config = FloorConfig::for_floor(1);
2078        let floor = FloorGenerator::generate(&config, 99);
2079        let start_tile = floor.map.get_tile(floor.map.player_start.x, floor.map.player_start.y);
2080        let exit_tile = floor.map.get_tile(floor.map.exit_point.x, floor.map.exit_point.y);
2081        assert_eq!(start_tile, Tile::StairsUp);
2082        assert_eq!(exit_tile, Tile::StairsDown);
2083    }
2084
2085    #[test]
2086    fn test_room_population_combat() {
2087        let mut rng = Rng::new(42);
2088        let rect = IRect::new(5, 5, 10, 10);
2089        let mut room = DungeonRoom {
2090            id: 0,
2091            rect,
2092            room_type: RoomType::Combat,
2093            shape: RoomShape::Rectangle,
2094            connections: vec![],
2095            spawn_points: vec![],
2096            items: vec![],
2097            enemies: vec![],
2098            visited: false,
2099            cleared: false,
2100        };
2101        RoomPopulator::populate(&mut room, 10, 0, FloorBiome::Ruins, &mut rng);
2102        assert!(
2103            room.enemies.len() >= 2 && room.enemies.len() <= 6,
2104            "Combat room should have 2-6 enemies, got {}",
2105            room.enemies.len()
2106        );
2107    }
2108
2109    #[test]
2110    fn test_room_population_shop() {
2111        let mut rng = Rng::new(42);
2112        let rect = IRect::new(5, 5, 10, 10);
2113        let mut room = DungeonRoom {
2114            id: 0,
2115            rect,
2116            room_type: RoomType::Shop,
2117            shape: RoomShape::Rectangle,
2118            connections: vec![],
2119            spawn_points: vec![],
2120            items: vec![],
2121            enemies: vec![],
2122            visited: false,
2123            cleared: false,
2124        };
2125        RoomPopulator::populate(&mut room, 10, 0, FloorBiome::Ruins, &mut rng);
2126        assert!(!room.items.is_empty(), "Shop room should have merchant");
2127        let has_merchant = room.items.iter().any(|i| matches!(i.kind, RoomItemKind::Merchant { .. }));
2128        assert!(has_merchant, "Shop room should contain a Merchant item");
2129    }
2130
2131    #[test]
2132    fn test_enemy_scaling() {
2133        let base = BaseStats {
2134            hp: 100.0,
2135            damage: 20.0,
2136            defense: 5.0,
2137            speed: 1.0,
2138            xp_value: 10,
2139        };
2140        let scaled_f1 = EnemyScaler::scale_enemy(&base, 1, 0);
2141        let scaled_f50 = EnemyScaler::scale_enemy(&base, 50, 0);
2142        assert!(
2143            scaled_f50.hp > scaled_f1.hp,
2144            "Higher floor enemies should have more HP"
2145        );
2146        assert!(
2147            scaled_f50.damage > scaled_f1.damage,
2148            "Higher floor enemies should do more damage"
2149        );
2150
2151        // With corruption
2152        let scaled_corrupt = EnemyScaler::scale_enemy(&base, 50, 100);
2153        assert!(
2154            scaled_corrupt.hp > scaled_f50.hp,
2155            "Corruption should increase HP"
2156        );
2157    }
2158
2159    #[test]
2160    fn test_enemy_scaling_formula() {
2161        let base = BaseStats {
2162            hp: 100.0,
2163            damage: 20.0,
2164            defense: 5.0,
2165            speed: 1.0,
2166            xp_value: 10,
2167        };
2168        let scaled = EnemyScaler::scale_enemy(&base, 10, 0);
2169        let expected_hp = 100.0 * (1.0 + 10.0 * 0.08);
2170        assert!(
2171            (scaled.hp - expected_hp).abs() < 0.01,
2172            "HP should be base * (1 + floor * 0.08)"
2173        );
2174        let expected_dmg = 20.0 * (1.0 + 10.0 * 0.05);
2175        assert!(
2176            (scaled.damage - expected_dmg).abs() < 0.01,
2177            "Damage should be base * (1 + floor * 0.05)"
2178        );
2179    }
2180
2181    #[test]
2182    fn test_enemy_abilities_scale_with_floor() {
2183        assert_eq!(EnemyScaler::ability_count(5), 0);
2184        assert_eq!(EnemyScaler::ability_count(10), 1);
2185        assert_eq!(EnemyScaler::ability_count(30), 3);
2186    }
2187
2188    #[test]
2189    fn test_elite_prefix_only_after_floor_50() {
2190        let mut rng = Rng::new(42);
2191        assert!(EnemyScaler::elite_prefix(10, &mut rng).is_none());
2192        assert!(EnemyScaler::elite_prefix(49, &mut rng).is_none());
2193        // Floor 50+ should return some prefix
2194        let mut found = false;
2195        for _ in 0..20 {
2196            if EnemyScaler::elite_prefix(50, &mut rng).is_some() {
2197                found = true;
2198                break;
2199            }
2200        }
2201        assert!(found, "Floor 50+ should eventually produce an elite prefix");
2202    }
2203
2204    #[test]
2205    fn test_fog_of_war_initial_unseen() {
2206        let fog = FogOfWar::new(10, 10);
2207        for y in 0..10i32 {
2208            for x in 0..10i32 {
2209                assert_eq!(fog.get(x, y), Visibility::Unseen);
2210            }
2211        }
2212    }
2213
2214    #[test]
2215    fn test_fog_of_war_reveal() {
2216        let config = FloorConfig::for_floor(1);
2217        let floor = FloorGenerator::generate(&config, 42);
2218        let mut fog = FogOfWar::new(floor.map.width, floor.map.height);
2219        let center = floor.map.player_start;
2220        fog.reveal_around(center, 5, &floor.map);
2221        assert_eq!(fog.get(center.x, center.y), Visibility::Visible);
2222        assert!(fog.explored_count() > 0);
2223    }
2224
2225    #[test]
2226    fn test_fog_fade_visible_to_seen() {
2227        let mut fog = FogOfWar::new(5, 5);
2228        fog.set(2, 2, Visibility::Visible);
2229        assert_eq!(fog.get(2, 2), Visibility::Visible);
2230        fog.fade_visible();
2231        assert_eq!(fog.get(2, 2), Visibility::Seen);
2232    }
2233
2234    #[test]
2235    fn test_tile_properties() {
2236        assert!(Tile::Floor.is_walkable());
2237        assert!(!Tile::Wall.is_walkable());
2238        assert!(Tile::Lava.is_walkable());
2239        assert!(Tile::Lava.properties().damage_on_step.is_some());
2240        assert!(Tile::Wall.blocks_sight());
2241        assert!(!Tile::Floor.blocks_sight());
2242        assert!(Tile::Door.blocks_sight());
2243        assert_eq!(Tile::Water.properties().element, Some(Element::Ice));
2244    }
2245
2246    #[test]
2247    fn test_floor_config_auto_generation() {
2248        let c1 = FloorConfig::for_floor(1);
2249        assert_eq!(c1.biome, FloorBiome::Ruins);
2250        assert!(!c1.boss_floor);
2251
2252        let c10 = FloorConfig::for_floor(10);
2253        assert!(c10.boss_floor);
2254
2255        let c100 = FloorConfig::for_floor(100);
2256        assert!(c100.boss_floor);
2257        assert_eq!(c100.biome, FloorBiome::Cathedral);
2258    }
2259
2260    #[test]
2261    fn test_floor_theme_ranges() {
2262        let t5 = FloorTheme::for_floor(5);
2263        assert_eq!(t5.biome, FloorBiome::Ruins);
2264        assert!(!t5.traps_enabled);
2265
2266        let t20 = FloorTheme::for_floor(20);
2267        assert_eq!(t20.biome, FloorBiome::Crypt);
2268        assert!(t20.traps_enabled);
2269
2270        let t40 = FloorTheme::for_floor(40);
2271        assert_eq!(t40.biome, FloorBiome::Forge);
2272
2273        let t60 = FloorTheme::for_floor(60);
2274        assert_eq!(t60.biome, FloorBiome::Void);
2275
2276        let t80 = FloorTheme::for_floor(80);
2277        assert_eq!(t80.biome, FloorBiome::Abyss);
2278
2279        let t100 = FloorTheme::for_floor(100);
2280        assert_eq!(t100.biome, FloorBiome::Cathedral);
2281    }
2282
2283    #[test]
2284    fn test_biome_properties_populated() {
2285        for biome in &[
2286            FloorBiome::Ruins, FloorBiome::Crypt, FloorBiome::Library,
2287            FloorBiome::Forge, FloorBiome::Garden, FloorBiome::Void,
2288            FloorBiome::Chaos, FloorBiome::Abyss, FloorBiome::Cathedral,
2289            FloorBiome::Laboratory,
2290        ] {
2291            let props = biome.properties();
2292            assert!(props.ambient_light >= 0.0 && props.ambient_light <= 1.0);
2293            assert!(!props.flavor_text.is_empty());
2294            assert!(!props.music_vibe.is_empty());
2295        }
2296    }
2297
2298    #[test]
2299    fn test_dungeon_manager_lifecycle() {
2300        let mut mgr = DungeonManager::new(42);
2301        assert!(mgr.current_floor().is_none());
2302
2303        mgr.start();
2304        assert_eq!(mgr.floor_number(), 1);
2305        assert!(mgr.current_floor().is_some());
2306
2307        mgr.descend();
2308        assert_eq!(mgr.floor_number(), 2);
2309        assert!(mgr.current_floor().is_some());
2310
2311        mgr.ascend();
2312        assert_eq!(mgr.floor_number(), 1);
2313        assert!(mgr.current_floor().is_some());
2314    }
2315
2316    #[test]
2317    fn test_dungeon_manager_backtrack_preserves_floor() {
2318        let mut mgr = DungeonManager::new(42);
2319        mgr.start();
2320
2321        // Get room count on floor 1
2322        let f1_rooms = mgr.current_floor().unwrap().map.rooms.len();
2323
2324        mgr.descend();
2325        mgr.ascend();
2326
2327        // Floor 1 should have the same layout
2328        let f1_rooms_after = mgr.current_floor().unwrap().map.rooms.len();
2329        assert_eq!(f1_rooms, f1_rooms_after);
2330    }
2331
2332    #[test]
2333    fn test_minimap_render() {
2334        let config = FloorConfig::for_floor(1);
2335        let floor = FloorGenerator::generate(&config, 42);
2336        let mut fog = FogOfWar::new(floor.map.width, floor.map.height);
2337        fog.reveal_around(floor.map.player_start, 10, &floor.map);
2338        let glyphs = Minimap::render_minimap(&floor.map, floor.map.player_start, &fog);
2339        assert!(!glyphs.is_empty(), "Minimap should produce glyphs");
2340        let has_player = glyphs.iter().any(|g| g.ch == '@');
2341        assert!(has_player, "Minimap should contain player marker");
2342    }
2343
2344    #[test]
2345    fn test_room_shape_carve() {
2346        let rect = IRect::new(2, 2, 8, 8);
2347        let mut tiles = vec![Tile::Wall; 20 * 20];
2348        let mut rng = Rng::new(42);
2349        RoomShape::Rectangle.carve(&rect, &mut tiles, 20, &mut rng);
2350        // Check center of room is floor
2351        let center_idx = 6 * 20 + 6;
2352        assert_eq!(tiles[center_idx], Tile::Floor);
2353    }
2354
2355    #[test]
2356    fn test_boss_floor_100_has_algorithm_reborn() {
2357        let config = FloorConfig::for_floor(100);
2358        let floor = FloorGenerator::generate(&config, 42);
2359        let boss_room = floor.map.rooms.iter().find(|r| r.room_type == RoomType::Boss);
2360        assert!(boss_room.is_some(), "Floor 100 should have a boss room");
2361        if let Some(br) = boss_room {
2362            let has_algo = br.enemies.iter().any(|e| e.name == "The Algorithm Reborn");
2363            assert!(has_algo, "Floor 100 boss should be The Algorithm Reborn");
2364        }
2365    }
2366
2367    #[test]
2368    fn test_floor_map_walkable_neighbors() {
2369        let config = FloorConfig::for_floor(1);
2370        let floor = FloorGenerator::generate(&config, 42);
2371        // Player start should have at least one walkable neighbor
2372        let neighbors = floor.map.walkable_neighbors(floor.map.player_start);
2373        assert!(!neighbors.is_empty(), "Player start should have walkable neighbors");
2374    }
2375
2376    #[test]
2377    fn test_corridor_style_variants() {
2378        // Just ensure all three styles can be constructed without panic
2379        let styles = [CorridorStyle::Straight, CorridorStyle::Winding, CorridorStyle::Organic];
2380        let mut tiles = vec![Tile::Wall; 50 * 50];
2381        let mut rng = Rng::new(42);
2382        let from = IVec2::new(5, 5);
2383        let to = IVec2::new(20, 20);
2384        for style in &styles {
2385            let mut t = tiles.clone();
2386            let path = style.carve_corridor(from, to, &mut t, 50, 50, &mut rng);
2387            assert!(!path.is_empty(), "Corridor {:?} should produce a path", style);
2388        }
2389    }
2390}