Skip to main content

sokoban_elements/game_board/
index.rs

1use std::ops::{Index, IndexMut};
2
3use super::GameBoard;
4use crate::utilities::{index_x, index_y};
5use crate::{Point2D, TileKind};
6
7impl Index<(usize, usize)> for GameBoard {
8    type Output = TileKind;
9
10    /// `index` is `(x, y)`
11    ///
12    /// # Panics
13    ///
14    /// Panics if `index` is outside of board.
15    #[expect(clippy::panic)]
16    fn index(&self, index: (usize, usize)) -> &Self::Output {
17        self.data
18            .get(index.1)
19            .and_then(|row| row.get(index.0))
20            .unwrap_or_else(|| panic!("Invalid GameBoard index {index:?}!"))
21    }
22}
23
24impl IndexMut<(usize, usize)> for GameBoard {
25    /// `index` is `(x, y)`
26    ///
27    /// # Panics
28    ///
29    /// Panics if `index` is outside of board.
30    #[expect(clippy::panic)]
31    fn index_mut(&mut self, index: (usize, usize)) -> &mut Self::Output {
32        self.data
33            .get_mut(index.1)
34            .and_then(|row| row.get_mut(index.0))
35            .unwrap_or_else(|| panic!("Invalid GameBoard index {index:?}!"))
36    }
37}
38
39impl Index<usize> for GameBoard {
40    type Output = TileKind;
41
42    /// `index` is linear board position: `y * width + x`
43    ///
44    /// # Panics
45    ///
46    /// Panics if `index` is outside of board.
47    #[expect(clippy::panic)]
48    fn index(&self, index: usize) -> &Self::Output {
49        let width = self.width();
50        assert!(width != 0, "Invalid GameBoard index {index}!");
51
52        self.data
53            .get(index_y(index, width))
54            .and_then(|row| row.get(index_x(index, width)))
55            .unwrap_or_else(|| panic!("Invalid GameBoard index {index}!"))
56    }
57}
58
59impl IndexMut<usize> for GameBoard {
60    /// `index` is linear board position: `y * width + x`
61    ///
62    /// # Panics
63    ///
64    /// Panics if `index` is outside of board.
65    #[expect(clippy::panic)]
66    fn index_mut(&mut self, index: usize) -> &mut Self::Output {
67        let width = self.width();
68        assert!(width != 0, "Invalid GameBoard index {index}!");
69
70        self.data
71            .get_mut(index_y(index, width))
72            .and_then(|row| row.get_mut(index_x(index, width)))
73            .unwrap_or_else(|| panic!("Invalid GameBoard index {index}!"))
74    }
75}
76
77impl Index<Point2D> for GameBoard {
78    type Output = TileKind;
79
80    /// `index` is [`Point2D`](crate::Point2D) of the tile.
81    ///
82    /// # Panics
83    ///
84    /// Panics if `index` is outside of board.
85    #[expect(clippy::panic)]
86    fn index(&self, index: Point2D) -> &Self::Output {
87        // Rows are indexed directly instead of through `Point2D::idx()`: that one saturates, so an
88        // off board `x` would alias onto an on board tile instead of panicking.
89        self.data
90            .get(index.y)
91            .and_then(|row| row.get(index.x))
92            .unwrap_or_else(|| panic!("Invalid GameBoard index {index}!"))
93    }
94}
95
96impl IndexMut<Point2D> for GameBoard {
97    /// `index` is [`Point2D`](crate::Point2D) of the tile.
98    ///
99    /// # Panics
100    ///
101    /// Panics if `index` is outside of board.
102    #[expect(clippy::panic)]
103    fn index_mut(&mut self, index: Point2D) -> &mut Self::Output {
104        self.data
105            .get_mut(index.y)
106            .and_then(|row| row.get_mut(index.x))
107            .unwrap_or_else(|| panic!("Invalid GameBoard index {index}!"))
108    }
109}
110
111#[cfg(test)]
112mod tests {
113    use super::*;
114    use crate::{BoardSyntaxError, Tessellation};
115
116    fn board_text() -> String {
117        [
118            "    #####",
119            "    #   #",
120            "    #$  #",
121            "  ###  $##",
122            "  #  $ $ #",
123            "### # ## #   ######",
124            "#   # ## #####  ..#",
125            "# $  $          ..#",
126            "##### ### #@##  ..#",
127            "    #     #########",
128            "    #######",
129        ]
130        .join("\n")
131    }
132
133    fn board() -> Result<GameBoard, BoardSyntaxError> {
134        GameBoard::parsed(&board_text(), Tessellation::Sokoban)
135    }
136
137    #[test]
138    fn it_reads_tiles_by_x_and_y() -> Result<(), BoardSyntaxError> {
139        let board = board()?;
140
141        assert_eq!(board[(0, 0)], TileKind::Floor);
142        assert_eq!(board[(4, 0)], TileKind::Wall);
143        assert_eq!(board[(5, 2)], TileKind::Box);
144        assert_eq!(board[(16, 6)], TileKind::Goal);
145        assert_eq!(board[(11, 8)], TileKind::Pusher);
146
147        Ok(())
148    }
149
150    #[test]
151    fn when_indexed_by_position_it_matches_indexing_by_x_and_y() -> Result<(), BoardSyntaxError> {
152        let board = board()?;
153
154        for y in 0..board.height() {
155            for x in 0..board.width() {
156                assert_eq!(board[(x, y)], board[y * board.width() + x]);
157            }
158        }
159
160        Ok(())
161    }
162
163    #[test]
164    fn it_writes_tiles_by_x_and_y() -> Result<(), BoardSyntaxError> {
165        let mut board = board()?;
166        let boxes_count = board.boxes_count();
167
168        assert_eq!(board[(0, 0)], TileKind::Floor);
169
170        board[(0, 0)] = TileKind::Box;
171
172        assert_eq!(board[(0, 0)], TileKind::Box);
173        assert_eq!(board.boxes_count(), boxes_count + 1);
174
175        Ok(())
176    }
177
178    #[test]
179    fn it_writes_tiles_by_linear_position() -> Result<(), BoardSyntaxError> {
180        let mut board = board()?;
181        let position: usize = 4;
182
183        assert_eq!(board[position], TileKind::Wall);
184
185        board[position] = TileKind::Goal;
186
187        assert_eq!(board[position], TileKind::Goal);
188        assert_eq!(board[(4, 0)], TileKind::Goal);
189
190        Ok(())
191    }
192
193    #[test]
194    fn it_reads_tiles_by_point() -> Result<(), BoardSyntaxError> {
195        let board = board()?;
196
197        assert_eq!(board[Point2D::new(0, 0)], TileKind::Floor);
198        assert_eq!(board[Point2D::new(4, 0)], TileKind::Wall);
199        assert_eq!(board[Point2D::new(5, 2)], TileKind::Box);
200        assert_eq!(board[Point2D::new(16, 6)], TileKind::Goal);
201        assert_eq!(board[Point2D::new(11, 8)], TileKind::Pusher);
202
203        Ok(())
204    }
205
206    #[test]
207    fn when_indexed_by_point_it_matches_indexing_by_x_and_y() -> Result<(), BoardSyntaxError> {
208        let board = board()?;
209
210        for y in 0..board.height() {
211            for x in 0..board.width() {
212                assert_eq!(board[Point2D::new(x, y)], board[(x, y)]);
213                assert_eq!(board[Point2D::new(x, y)], board[y * board.width() + x]);
214            }
215        }
216
217        Ok(())
218    }
219
220    #[test]
221    fn it_writes_tiles_by_point() -> Result<(), BoardSyntaxError> {
222        let mut board = board()?;
223        let boxes_count = board.boxes_count();
224        // Deliberately asymmetric, and holding a different tile than its transposition: writing to
225        // `(5, 2)` must not touch `(2, 5)`, which is what catches a swapped `x` and `y`.
226        let position = Point2D::new(5, 2);
227
228        assert_eq!(board[position], TileKind::Box);
229        assert_eq!(board[(2, 5)], TileKind::Wall);
230
231        board[position] = TileKind::Goal;
232
233        assert_eq!(board[position], TileKind::Goal);
234        assert_eq!(board[(5, 2)], TileKind::Goal);
235        assert_eq!(board[(2, 5)], TileKind::Wall, "x and y must not be swapped");
236        assert_eq!(board.boxes_count(), boxes_count - 1);
237
238        Ok(())
239    }
240
241    #[test]
242    #[should_panic(expected = "Invalid GameBoard index")]
243    fn it_panics_when_x_and_y_are_out_of_bounds() {
244        let board = GameBoard::new(3, 3, Tessellation::Sokoban);
245        let _ = board[(0, board.height())];
246    }
247
248    #[test]
249    #[should_panic(expected = "Invalid GameBoard index")]
250    fn it_panics_when_position_is_out_of_bounds() {
251        let board = GameBoard::new(3, 3, Tessellation::Sokoban);
252        let _ = board[board.width() * board.height()];
253    }
254
255    #[test]
256    #[should_panic(expected = "Invalid GameBoard index")]
257    fn when_board_is_empty_indexing_by_position_panics() {
258        let board = GameBoard::new(0, 0, Tessellation::Sokoban);
259        let position: usize = 0;
260        let _ = board[position];
261    }
262
263    /// An off board `x` must panic even when `y * width + x` names an on board tile: `(3, 0)` on a
264    /// 3 wide board computes the same linear index as `(0, 1)`. Indexing through
265    /// [`Point2D::idx()`], which saturates instead of failing, would return that other tile and
266    /// not panic at all.
267    #[test]
268    #[should_panic(expected = "Invalid GameBoard index")]
269    fn it_panics_when_point_x_is_out_of_bounds() {
270        let board = GameBoard::new(3, 3, Tessellation::Sokoban);
271        let _ = board[Point2D::new(board.width(), 0)];
272    }
273
274    #[test]
275    #[should_panic(expected = "Invalid GameBoard index")]
276    fn it_panics_when_point_y_is_out_of_bounds() {
277        let board = GameBoard::new(3, 3, Tessellation::Sokoban);
278        let _ = board[Point2D::new(0, board.height())];
279    }
280
281    #[test]
282    #[should_panic(expected = "Invalid GameBoard index")]
283    fn when_board_is_empty_indexing_by_point_panics() {
284        let board = GameBoard::new(0, 0, Tessellation::Sokoban);
285        let _ = board[Point2D::origin()];
286    }
287}