utils/game/
mine_sweeper.rs

1//! Mine Sweeper
2
3use anyhow::{Context, Result, bail, ensure};
4use rand::seq::SliceRandom;
5use serde::Serialize;
6use std::ops::RangeInclusive;
7
8pub const W_MAX: i32 = 30;
9pub const H_MAX: i32 = 16;
10const W_RANGE: RangeInclusive<i32> = 1..=W_MAX;
11const H_RANGE: RangeInclusive<i32> = 1..=W_MAX;
12
13const DXY: &[(i32, i32)] = &[
14    (-1, -1),
15    (-1, 0),
16    (-1, 1),
17    (0, -1),
18    (0, 1),
19    (1, -1),
20    (1, 0),
21    (1, 1),
22];
23
24#[derive(Debug, Clone, Copy, Default, Serialize)]
25pub enum GameState {
26    #[default]
27    Initialized,
28    Playing,
29    Cleared,
30    Failed,
31}
32
33#[derive(Debug, Clone, Copy)]
34pub enum Cell {
35    Mine,
36    Number(u8),
37}
38
39#[derive(Debug, Clone, Copy)]
40pub struct Config {
41    pub width: i32,
42    pub height: i32,
43    pub mine_count: i32,
44}
45
46pub enum Level {
47    Easy,
48    Normal,
49    Hard,
50}
51
52#[derive(Debug, Clone, Serialize)]
53struct MsStateJson {
54    state: GameState,
55    width: i32,
56    height: i32,
57    mine_count: i32,
58    board: Vec<String>,
59}
60
61impl Level {
62    pub fn to_config(&self) -> Config {
63        match self {
64            Self::Easy => Config {
65                width: 9,
66                height: 9,
67                mine_count: 10,
68            },
69            Self::Normal => Config {
70                width: 16,
71                height: 16,
72                mine_count: 40,
73            },
74            Self::Hard => Config {
75                width: 30,
76                height: 16,
77                mine_count: 99,
78            },
79        }
80    }
81}
82
83pub struct MineSweeper {
84    pub state: GameState,
85    pub width: i32,
86    pub height: i32,
87    pub mine_count: i32,
88    board: Vec<Cell>,
89    revealed: Vec<bool>,
90}
91
92impl MineSweeper {
93    pub fn new(config: Config) -> Result<Self> {
94        let width = config.width;
95        let height = config.height;
96        ensure!(W_RANGE.contains(&width));
97        ensure!(H_RANGE.contains(&height));
98        // NG if =0 or =size
99        let size = width * height;
100        let mine_count = config.mine_count;
101        ensure!((1..size).contains(&mine_count));
102
103        // Put mines at random
104        let mut rng = rand::rng();
105        let mut board: Vec<_> = std::iter::repeat_n(Cell::Mine, mine_count as usize)
106            .chain(std::iter::repeat_n(
107                Cell::Number(0),
108                (size - mine_count) as usize,
109            ))
110            .collect();
111        board.shuffle(&mut rng);
112
113        // Count mines around each cell
114        for y in 0..height {
115            for x in 0..width {
116                let idx = Self::convert_raw(x, y, width, height).unwrap();
117                if matches!(board[idx], Cell::Mine) {
118                    continue;
119                }
120                let mut count = 0;
121                for (dx, dy) in DXY {
122                    let nx = x + dx;
123                    let ny = y + dy;
124                    if let Some(nidx) = Self::convert_raw(nx, ny, width, height)
125                        && matches!(board[nidx], Cell::Mine)
126                    {
127                        count += 1;
128                    }
129                }
130                board[idx] = Cell::Number(count);
131            }
132        }
133
134        let obj = Self {
135            state: GameState::Initialized,
136            width,
137            height,
138            mine_count,
139            board,
140            revealed: vec![false; (config.width * config.height) as usize],
141        };
142
143        Ok(obj)
144    }
145
146    pub fn to_json(&self) -> String {
147        serde_json::to_string(&self.to_json_raw()).unwrap()
148    }
149
150    pub fn to_json_pretty(&self) -> String {
151        serde_json::to_string_pretty(&self.to_json_raw()).unwrap()
152    }
153
154    fn to_json_raw(&self) -> MsStateJson {
155        let mut board = Vec::with_capacity(self.height as usize);
156        for y in 0..self.height {
157            let mut line = String::with_capacity(self.width as usize);
158            for x in 0..self.width {
159                let idx = self.convert(x, y).unwrap();
160                let c = if self.revealed[idx] {
161                    match self.board[idx] {
162                        Cell::Mine => '*',
163                        Cell::Number(n) => (b'0' + n) as char,
164                    }
165                } else {
166                    '.'
167                };
168                line.push(c);
169            }
170            board.push(line);
171        }
172
173        MsStateJson {
174            state: self.state,
175            width: self.width,
176            height: self.height,
177            mine_count: self.mine_count,
178            board,
179        }
180    }
181
182    pub fn reveal(&mut self, x: i32, y: i32) -> Result<GameState> {
183        if !(matches!(self.state, GameState::Initialized | GameState::Playing)) {
184            bail!("Already finished");
185        }
186
187        let _ = self.convert(x, y).context("Invalid x y")?;
188
189        self.reveal_raw(x, y).context("Already revealed")?;
190        self.state = self.check_state();
191
192        Ok(self.state)
193    }
194
195    fn reveal_raw(&mut self, x: i32, y: i32) -> Option<Cell> {
196        let idx = self.convert(x, y)?;
197        if !self.revealed[idx] {
198            self.revealed[idx] = true;
199            if let Cell::Number(n) = self.board[idx]
200                && n == 0
201            {
202                for (dy, dx) in DXY {
203                    let nx = x + dx;
204                    let ny = y + dy;
205                    self.reveal_raw(nx, ny);
206                }
207            }
208            Some(self.board[idx])
209        } else {
210            None
211        }
212    }
213
214    fn check_state(&self) -> GameState {
215        debug_assert_eq!(self.board.len(), self.revealed.len());
216
217        let mut not_cleared = false;
218        for (cell, revealed) in self.board.iter().zip(self.revealed.iter()) {
219            if *revealed {
220                if matches!(cell, Cell::Mine) {
221                    return GameState::Failed;
222                }
223            } else if !matches!(cell, Cell::Mine) {
224                not_cleared = true;
225            }
226        }
227
228        if not_cleared {
229            GameState::Playing
230        } else {
231            GameState::Cleared
232        }
233    }
234
235    fn convert(&self, x: i32, y: i32) -> Option<usize> {
236        Self::convert_raw(x, y, self.width, self.height)
237    }
238
239    fn convert_raw(x: i32, y: i32, w: i32, h: i32) -> Option<usize> {
240        if (0..w).contains(&x) && (0..h).contains(&y) {
241            Some((y * w + x) as usize)
242        } else {
243            None
244        }
245    }
246}
247
248#[cfg(test)]
249mod test {
250    use super::*;
251
252    #[test]
253    #[ignore]
254    // cargo test ms_play -- --ignored --nocapture
255    fn ms_play() -> Result<()> {
256        let mut ms = MineSweeper::new(Level::Easy.to_config()).unwrap();
257        loop {
258            println!("{}", ms.to_json_pretty());
259
260            let mut input = String::new();
261            println!("Enter x y (or 'exit'):");
262            std::io::stdin().read_line(&mut input).unwrap();
263            let input = input.trim();
264            if input == "exit" {
265                break Ok(());
266            }
267
268            let mut iter = input.split_whitespace();
269            let x: i32 = iter.next().unwrap().parse().unwrap();
270            let y: i32 = iter.next().unwrap().parse().unwrap();
271            match ms.reveal(x, y) {
272                Ok(state) => match state {
273                    GameState::Playing => println!("Playing"),
274                    GameState::Cleared => {
275                        println!("{}", ms.to_json_pretty());
276                        println!("Cleared");
277                        break Ok(());
278                    }
279                    GameState::Failed => {
280                        println!("{}", ms.to_json_pretty());
281                        println!("Failed");
282                        break Ok(());
283                    }
284                    _ => {}
285                },
286                Err(e) => println!("Error: {e}"),
287            }
288        }
289    }
290
291    #[test]
292    fn ms_init() {
293        for _ in 0..100 {
294            let _ms = MineSweeper::new(Level::Easy.to_config()).unwrap();
295            let _ms = MineSweeper::new(Level::Normal.to_config()).unwrap();
296            let _ms = MineSweeper::new(Level::Hard.to_config()).unwrap();
297        }
298    }
299}