Skip to main content

play_2048/
solver.rs

1use crate::board::{Board, Direction};
2use crate::evaluators::{BoardEvaluator, MonotonicityEvaluator, PrecomputedBoardEvaluator};
3use fnv::FnvHashMap;
4use std::cmp::max;
5
6pub struct Solver {
7    board_evaluator: Box<dyn BoardEvaluator>,
8    proba_2: f32,
9    proba_4: f32,
10    base_max_search_depth: usize,
11    min_branch_proba: f32,
12    transposition_table: FnvHashMap<Board, (f32, f32)>,
13}
14
15pub struct SolverBuilder {
16    board_evaluator: Box<dyn BoardEvaluator>,
17    proba_4: f32,
18    base_max_search_depth: usize,
19    min_branch_proba: f32,
20}
21
22impl Default for SolverBuilder {
23    fn default() -> Self {
24        Self {
25            board_evaluator: Box::new(PrecomputedBoardEvaluator::new(
26                MonotonicityEvaluator::default(),
27            )),
28            proba_4: 0.1,
29            base_max_search_depth: 3,
30            min_branch_proba: 0.1 * 0.1,
31        }
32    }
33}
34
35impl SolverBuilder {
36    /// Sets the `BoardEvaluator` implementation to use in the solver
37    pub fn board_evaluator<T>(mut self, evaluator: T) -> Self
38    where
39        T: BoardEvaluator + 'static,
40    {
41        self.board_evaluator = Box::new(evaluator);
42        self
43    }
44
45    /// Sets the probability weight associated to the draw of a 4 tile
46    pub fn proba_4(mut self, proba_4: f32) -> Self {
47        self.proba_4 = proba_4;
48        self
49    }
50
51    /// Sets the max search depth which will be used in the expectiminimax algorithm
52    /// This value is adjusted at each move to take into account the difficulty of the board.
53    /// It is thus the max depth which will be used in easy configurations. The effective
54    /// max depth will be higher for more difficult ones.
55    pub fn base_max_search_depth(mut self, max_search_depth: usize) -> Self {
56        self.base_max_search_depth = max_search_depth;
57        self
58    }
59
60    /// Sets the minimum probability for a branch to be explored
61    pub fn min_branch_proba(mut self, proba: f32) -> Self {
62        self.min_branch_proba = proba;
63        self
64    }
65
66    pub fn build(self) -> Solver {
67        Solver {
68            board_evaluator: self.board_evaluator,
69            proba_2: 1. - self.proba_4,
70            proba_4: self.proba_4,
71            base_max_search_depth: self.base_max_search_depth,
72            min_branch_proba: self.min_branch_proba,
73            transposition_table: Default::default(),
74        }
75    }
76}
77
78impl Solver {
79    pub fn next_best_move(&mut self, board: Board) -> Option<Direction> {
80        let max_depth = self.compute_max_depth(board);
81        self.transposition_table = FnvHashMap::default();
82        self.eval_max(board, max_depth as usize, 1.0)
83            .map(|(d, _)| d)
84    }
85
86    fn compute_max_depth(&self, board: Board) -> usize {
87        let adjustment_factor = match board.max_value() {
88            2048 => 4,
89            4096 => 2,
90            8192 => 2,
91            16384 => 1,
92            32768 => 0,
93            _ => 7,
94        };
95        max(
96            self.base_max_search_depth as isize,
97            board.count_distinct_tiles() as isize - adjustment_factor,
98        ) as usize
99    }
100
101    fn eval_max(
102        &mut self,
103        board: Board,
104        remaining_depth: usize,
105        branch_proba: f32,
106    ) -> Option<(Direction, f32)> {
107        Direction::all()
108            .iter()
109            .filter_map(|d| {
110                let new_board = board.move_to(*d);
111                if board == new_board {
112                    return None;
113                }
114                Some((
115                    *d,
116                    self.eval_average(new_board, remaining_depth, branch_proba),
117                ))
118            })
119            .max_by(|(_, lhs), (_, rhs)| lhs.partial_cmp(rhs).unwrap())
120    }
121
122    fn eval_average(&mut self, board: Board, remaining_depth: usize, branch_proba: f32) -> f32 {
123        if remaining_depth == 0 || branch_proba < self.min_branch_proba {
124            return self.board_evaluator.evaluate(board);
125        }
126
127        if let Some((cached_value, cached_proba)) = self.transposition_table.get(&board) {
128            if *cached_proba >= branch_proba {
129                return *cached_value;
130            }
131        }
132
133        let empty_tiles_indices = board.empty_tiles_indices();
134        let nb_empty_tiles = board.count_empty_tiles() as f32;
135        let proba_2 = self.proba_2;
136        let proba_4 = self.proba_4;
137        let scores_sum: f32 = empty_tiles_indices
138            .map(|idx| {
139                let board_with_2 = board.set_value_by_exponent(idx, 1);
140                let board_with_4 = board.set_value_by_exponent(idx, 2);
141                let max_score_2 = self
142                    .eval_max(
143                        board_with_2,
144                        remaining_depth - 1,
145                        branch_proba * proba_2 / nb_empty_tiles,
146                    )
147                    .map(|(_, score)| score)
148                    .unwrap_or_else(|| self.board_evaluator.gameover_penalty());
149                let max_score_4 = self
150                    .eval_max(
151                        board_with_4,
152                        remaining_depth - 1,
153                        branch_proba * proba_4 / nb_empty_tiles,
154                    )
155                    .map(|(_, score)| score)
156                    .unwrap_or_else(|| self.board_evaluator.gameover_penalty());
157                max_score_2 * proba_2 + max_score_4 * proba_4
158            })
159            .sum();
160        let average = scores_sum / nb_empty_tiles as f32;
161        self.transposition_table
162            .insert(board, (average, branch_proba));
163        average
164    }
165}
166
167#[cfg(test)]
168mod tests {
169    use super::*;
170
171    #[test]
172    fn test_next_best_move() {
173        // Given
174        struct DummyEvaluator;
175        impl BoardEvaluator for DummyEvaluator {
176            fn evaluate(&self, board: Board) -> f32 {
177                board.max_value() as f32 / 32768.
178            }
179
180            fn gameover_penalty(&self) -> f32 {
181                0.
182            }
183        }
184
185        let mut solver = SolverBuilder::default()
186            .board_evaluator(DummyEvaluator {})
187            .base_max_search_depth(2)
188            .build();
189
190        #[rustfmt::skip]
191        let board: Board = Board::from(vec![
192            4, 4, 0, 4,
193            16, 0, 0, 2,
194            0, 8, 0, 16,
195            0, 8, 0, 16,
196        ]);
197
198        // When
199        let direction = solver.next_best_move(board);
200
201        // Then
202        assert_eq!(Some(Direction::Down), direction);
203    }
204}