From 65e8eb79984d713db4fd037f7be88e2d2cbf4e25 Mon Sep 17 00:00:00 2001 From: Mark Date: Tue, 5 Mar 2024 10:17:56 -0800 Subject: [PATCH] Reworked minimax agent --- src/agents/minimax.rs | 68 +++++++++++ src/agents/minmaxtree.rs | 216 ---------------------------------- src/agents/mod.rs | 6 +- src/agents/util/mod.rs | 3 - src/agents/util/partials.rs | 211 +++++++++++++++++++-------------- src/agents/util/treecoords.rs | 137 --------------------- src/board/board.rs | 115 ++++++++++-------- 7 files changed, 262 insertions(+), 494 deletions(-) create mode 100644 src/agents/minimax.rs delete mode 100644 src/agents/minmaxtree.rs delete mode 100644 src/agents/util/treecoords.rs diff --git a/src/agents/minimax.rs b/src/agents/minimax.rs new file mode 100644 index 0000000..675f863 --- /dev/null +++ b/src/agents/minimax.rs @@ -0,0 +1,68 @@ +use anyhow::Result; +use std::num::NonZeroU8; + +use super::{MaximizerAgent, MinimizerAgent, RandomAgent}; +use crate::{ + agents::util::free_slots_by_influence, + board::{Board, PlayerAction}, + util::Symb, +}; + +pub struct SimpleMinimax {} + +impl SimpleMinimax { + fn step(&mut self, board: &Board, minimize: bool) -> Result { + let available_numbers = (0..=9) + .map(|x| match x { + 0 => Symb::Zero, + x => Symb::Number(NonZeroU8::new(x).unwrap()), + }) + .filter(|x| !board.contains(*x)) + .collect::>(); + + // For the code below, we must guarantee that + // min_slots + max_slots <= available_numbers.len + let n_free = board.get_board().iter().filter(|x| x.is_none()).count(); + if available_numbers.len() < n_free || n_free >= 10 { + return RandomAgent {}.step_min(board); + } + + let t = free_slots_by_influence(&board); + + if t.len() == 0 { + return RandomAgent {}.step_min(board); + } + + let (pos, val) = t[0]; + + let symb = { + if minimize { + if val >= 0.0 { + available_numbers[0] + } else { + available_numbers[available_numbers.len() - 1] + } + } else { + if val <= 0.0 { + available_numbers[0] + } else { + available_numbers[available_numbers.len() - 1] + } + } + }; + + Ok(PlayerAction { symb, pos }) + } +} + +impl MinimizerAgent for SimpleMinimax { + fn step_min(&mut self, board: &Board) -> Result { + self.step(board, true) + } +} + +impl MaximizerAgent for SimpleMinimax { + fn step_max(&mut self, board: &Board) -> Result { + self.step(board, false) + } +} diff --git a/src/agents/minmaxtree.rs b/src/agents/minmaxtree.rs deleted file mode 100644 index 6d2b00e..0000000 --- a/src/agents/minmaxtree.rs +++ /dev/null @@ -1,216 +0,0 @@ -use std::{iter, num::NonZeroU8}; - -use anyhow::Result; -use itertools::Itertools; - -use super::{ - util::{fill_partials, TreeCoords}, - MaximizerAgent, MinimizerAgent, RandomAgent, -}; -use crate::{ - agents::util::{find_partials, free_chars}, - board::{Board, PlayerAction, TreeElement}, - util::Symb, -}; - -pub struct MinMaxTree {} - -fn find_best_numbers_v1<'a, F>( - tree: &TreeElement, - partials: &[TreeCoords], - numbers: impl Iterator, - minimize: bool, - - // Returns true if we want to maximize the given partial, - // and false if we want to fix it. - filter: F, -) -> Vec -where - F: Fn(&&TreeCoords) -> bool, -{ - // Fill maximizer slots with arbitrary numbers - let min_tree_base = fill_partials( - tree, - partials.iter().filter(|x| !filter(x)), - iter::repeat(&Symb::Number(NonZeroU8::new(5).unwrap())), - ); - - let partials_to_optimize: Vec = partials.iter().filter(filter).cloned().collect(); - let n_empty = free_chars(tree, partials_to_optimize.iter()).len(); - println!("{:?}", n_empty); - - let trees: Vec<(f32, Vec<&Symb>)> = numbers - .permutations(n_empty) - .unique() - .filter_map(move |l| { - let mut i = l.iter(); - let mut tmp_tree = min_tree_base.clone(); - for p in &partials_to_optimize { - let x = p.get_from_mut(&mut tmp_tree).unwrap(); - - let x_str = match x { - TreeElement::Partial(s) => s, - _ => unreachable!(), - }; - let mut new_str = String::new(); - for c in x_str.chars() { - if c == '_' { - new_str.push_str(&format!("{}", i.next().unwrap())) - } else { - new_str.push(c); - } - } - *x = TreeElement::Number(new_str.parse().unwrap()) - } - - println!("{:?}", tmp_tree); - tmp_tree.evaluate().map(|x| (x, l)) - }) - .collect(); - - let mut best_list: Option> = None; - let mut best_value: Option = None; - - for (x, list) in trees { - if let Some(m) = best_value { - if (minimize && x < m) || (!minimize && x > m) { - best_value = Some(x); - best_list = Some(list); - } - } else { - best_value = Some(x); - best_list = Some(list); - } - } - - best_list.unwrap().into_iter().cloned().collect() -} - -fn find_best_numbers( - tree: &TreeElement, - partials: &[TreeCoords], - - // The numbers we're allowed to add, sorted in ascending order - available_numbers: &[Symb], -) -> TreeElement { - // Fill all empty slots with fives - let tree_filled = fill_partials( - tree, - partials.iter(), - iter::repeat(&Symb::Number(NonZeroU8::new(5).unwrap())), - ); - - let base = tree_filled.evaluate().unwrap(); - - // Test each slot: - // Increase its value by 1, and record its effect on the - // expression's total value. - // This isn't a perfect metric, but it's pretty good. - let mut slots: Vec<(usize, &TreeCoords, usize, f32)> = free_chars(tree, partials.iter()) - .into_iter() - .enumerate() - .map(|(i_slot, (c, i))| { - let mut new_tree = tree_filled.clone(); - let p = c.get_from_mut(&mut new_tree).unwrap(); - match p { - TreeElement::Partial(s) => s.replace_range(i..i + 1, "6"), - _ => unreachable!(), - } - // This shouldn't ever be None. - (i_slot, c, i, new_tree.evaluate().unwrap() - base) - }) - .collect(); - - // Sort by least to most influence - slots.sort_by(|a, b| a.3.partial_cmp(&b.3).unwrap()); - - let all_symbols = { - // We need this many from the bottom, and this many from the top. - let neg_count = slots.iter().filter(|(_, _, _, x)| *x <= 0.0).count(); - let pos_count = slots.iter().filter(|(_, _, _, x)| *x > 0.0).count(); - - let mut a_iter = available_numbers - .iter() - .take(neg_count) - .chain(available_numbers.iter().rev().take(pos_count).rev()); - - let mut g = slots - // Group slots with equal weights - // and count the number of elements in each group - .iter() - .group_by(|x| x.3) - .into_iter() - .map(|(_, x)| x.count()) - // Generate the digits we should try for each group of - // equal-weight slots - .map(|s| { - (0..s) - .map(|_| a_iter.next().unwrap().clone()) - .permutations(s) - .unique() - .collect_vec() - }) - // Now, covert this to an array of all cartesian products - // of this set of sets - .multi_cartesian_product() - .map(|x| x.iter().flatten().cloned().collect_vec()) - .map(|v| slots.iter().zip(v).collect_vec()) - .collect_vec(); - - // Sort these vectors so the order of values - // matches the order of empty slots - g.iter_mut() - .for_each(|v| v.sort_by(|(a, _), (b, _)| a.0.partial_cmp(&b.0).unwrap())); - g.into_iter() - .map(|v| v.into_iter().map(|(_, s)| s).collect_vec()) - }; - - let mut best_tree = None; - let mut best_value = None; - for i in all_symbols { - let tmp_tree = fill_partials(&tree, partials.iter(), i.iter()); - let val = tmp_tree.evaluate(); - - if let Some(val) = val { - if let Some(best) = best_value { - if val > best { - best_value = Some(val); - best_tree = Some(tmp_tree) - } - } else { - best_value = Some(val); - best_tree = Some(tmp_tree) - } - } - } - - best_tree.unwrap() -} - -impl MinMaxTree {} - -impl MinimizerAgent for MinMaxTree { - fn step_min(&mut self, board: &Board) -> Result { - let tree = board.to_tree(); - let partials = find_partials(&tree); - - let available_numbers = (0..=9) - .map(|x| match x { - 0 => Symb::Zero, - x => Symb::Number(NonZeroU8::new(x).unwrap()), - }) - .filter(|x| !board.contains(*x)) - .collect::>(); - - // For the code below, we must guarantee that - // that is, min_slots + max_slots <= available_numbers.len - if available_numbers.len() < free_chars(&tree, partials.iter()).len() { - return RandomAgent {}.step_max(board); - } - - let t = find_best_numbers(&tree, &partials, &available_numbers); - - println!("{:?}", t); - RandomAgent {}.step_max(board) - } -} diff --git a/src/agents/mod.rs b/src/agents/mod.rs index 3ca931b..ec49bb1 100644 --- a/src/agents/mod.rs +++ b/src/agents/mod.rs @@ -1,11 +1,11 @@ mod diffuse; -mod minmaxtree; +mod minimax; mod player; mod random; pub mod util; -pub use diffuse::DiffuseAgent; -pub use minmaxtree::MinMaxTree; +pub use diffuse::Diffuse; +pub use minimax::SimpleMinimax; pub use player::PlayerAgent; pub use random::RandomAgent; diff --git a/src/agents/util/mod.rs b/src/agents/util/mod.rs index ca245af..3bf8514 100644 --- a/src/agents/util/mod.rs +++ b/src/agents/util/mod.rs @@ -1,6 +1,3 @@ /// Common helper functions that may be used by agents. mod partials; -mod treecoords; - pub use partials::*; -pub use treecoords::*; diff --git a/src/agents/util/partials.rs b/src/agents/util/partials.rs index d9f9f79..8fd9d42 100644 --- a/src/agents/util/partials.rs +++ b/src/agents/util/partials.rs @@ -1,96 +1,129 @@ -use super::{TreeCoords, TreeDir}; -use crate::{board::TreeElement, util::Symb}; +use itertools::Itertools; +use std::num::NonZeroU8; -/// Find the coordinates of all partials in the given tree -pub fn find_partials(tree: &TreeElement) -> Vec { - let mut partials = Vec::new(); - let mut current_coords = TreeCoords::new(); +use crate::{board::Board, util::Symb}; - loop { - let t = current_coords.get_from(tree).unwrap(); - match t { - TreeElement::Number(_) | TreeElement::Partial(_) => { - if let TreeElement::Partial(_) = t { - partials.push(current_coords); - } +/// Returns an iterator of (sort, coords, char_idx, f32) for each empty slot in the listed partials. +/// - sort is the index of this slot. +/// - coords are the coordinate of this slot's partial +/// - char_idx is the index of this slot in its partial +/// - f32 is the influence of this slot +pub fn free_slots_by_influence(board: &Board) -> Vec<(usize, f32)> { + // Fill all empty slots with fives and compute starting value + let filled = Board::from_board(board.get_board().map(|x| match x { + None => Symb::from_char('5'), + _ => x, + })); - loop { - match current_coords.pop() { - Some((TreeDir::Left, _)) => { - current_coords.push( - TreeDir::Right, - match current_coords.get_from(tree) { - Some(TreeElement::Add { .. }) => current_coords.is_inverted(), - Some(TreeElement::Mul { .. }) => current_coords.is_inverted(), - Some(TreeElement::Sub { .. }) => !current_coords.is_inverted(), - Some(TreeElement::Div { .. }) => !current_coords.is_inverted(), - _ => unreachable!(), - }, - ); - break; - } - Some((TreeDir::Right, _)) => {} - Some((TreeDir::This, _)) => unreachable!(), - None => return partials, - } - } - } - TreeElement::Div { .. } - | TreeElement::Mul { .. } - | TreeElement::Sub { .. } - | TreeElement::Add { .. } => current_coords.push(TreeDir::Left, current_coords.is_inverted()), - TreeElement::Neg { .. } => { - current_coords.push(TreeDir::Right, !current_coords.is_inverted()) - } - } - } -} + let base = filled.evaluate().unwrap(); -/// Fill empty slots in the given partials, in order. -/// Will panic if we run out of numbers to fill with. -/// -/// Returns a new tree with filled partials. -pub fn fill_partials<'a>( - tree: &'a TreeElement, - partials: impl Iterator, - mut numbers: impl Iterator, -) -> TreeElement { - let mut tmp_tree = tree.clone(); - for p in partials { - let x = p.get_from_mut(&mut tmp_tree).unwrap(); + // Test each slot: + // Increase its value by 1, and record its effect on the + // expression's total value. + // This isn't a perfect metric, but it's pretty good. + let mut slots: Vec<(usize, f32)> = board + .get_board() + .iter() + .enumerate() + .filter_map(|(i, s)| if s.is_some() { None } else { Some(i) }) + .map(|i| { + let mut new_tree = filled.clone(); + new_tree.get_board_mut()[i] = Some(Symb::from_char('6').unwrap()); - let x_str = match x { - TreeElement::Partial(s) => s, - _ => unreachable!(), - }; - let mut new_str = String::new(); - for c in x_str.chars() { - if c == '_' { - new_str.push_str(&format!("{}", numbers.next().unwrap())) - } else { - new_str.push(c); - } - } - *x = TreeElement::Partial(new_str) - } - - tmp_tree -} - -/// Find all empty slots in the given partials -/// Returns (coords of partial, index of slot in string) -pub fn free_chars<'a>( - tree: &'a TreeElement, - partials: impl Iterator, -) -> Vec<(&TreeCoords, usize)> { - partials - .flat_map(|x| match x.get_from(tree) { - Some(TreeElement::Partial(s)) => { - s.chars() - .enumerate() - .filter_map(move |(i, c)| if c == '_' { Some((x, i)) } else { None }) - } - _ => unreachable!(), + // This shouldn't ever be None + (i, new_tree.evaluate().unwrap() - base) }) - .collect() + .collect(); + + // Sort by most to least influence + slots.sort_by(|a, b| b.1.abs().partial_cmp(&a.1.abs()).unwrap()); + slots +} + +/// Find the maximum possible value of the given board +#[allow(dead_code)] +pub fn maximize_value(board: &Board) -> Board { + let n_free = board.get_board().iter().filter(|x| x.is_none()).count(); + + // Assume we have 10 or fewer available slots + if n_free >= 10 { + panic!() + } + + let available_numbers = (0..=9) + .map(|x| match x { + 0 => Symb::Zero, + x => Symb::Number(NonZeroU8::new(x).unwrap()), + }) + .filter(|x| !board.contains(*x)) + .collect::>(); + + let slots = free_slots_by_influence(&board); + + let all_symbols = { + // We need this many from the bottom, and this many from the top. + let neg_count = slots.iter().filter(|(_, x)| *x <= 0.0).count(); + let pos_count = slots.iter().filter(|(_, x)| *x > 0.0).count(); + + let mut a_iter = available_numbers + .iter() + .take(neg_count) + .chain(available_numbers.iter().rev().take(pos_count).rev()); + + let mut g = slots + // Group slots with equal weights + // and count the number of elements in each group + .iter() + .group_by(|x| x.1) + .into_iter() + .map(|(_, x)| x.count()) + // Generate the digits we should try for each group of + // equal-weight slots + .map(|s| { + (0..s) + .map(|_| a_iter.next().unwrap().clone()) + .permutations(s) + .unique() + .collect_vec() + }) + // Now, covert this to an array of all cartesian products + // of this set of sets + .multi_cartesian_product() + .map(|x| x.iter().flatten().cloned().collect_vec()) + .map(|v| slots.iter().zip(v).collect_vec()) + .collect_vec(); + + // Sort these vectors so the order of values + // matches the order of empty slots + g.iter_mut() + .for_each(|v| v.sort_by(|(a, _), (b, _)| b.0.partial_cmp(&a.0).unwrap())); + g.into_iter() + .map(|v| v.into_iter().map(|(_, s)| s).collect_vec()) + }; + + let mut best_board = None; + let mut best_value = None; + for i in all_symbols { + let mut i_iter = i.iter(); + let filled = Board::from_board(board.get_board().map(|x| match x { + None => i_iter.next().cloned(), + _ => x, + })); + + let val = filled.evaluate(); + + if let Some(val) = val { + if let Some(best) = best_value { + if val > best { + best_value = Some(val); + best_board = Some(filled) + } + } else { + best_value = Some(val); + best_board = Some(filled) + } + } + } + + best_board.unwrap() } diff --git a/src/agents/util/treecoords.rs b/src/agents/util/treecoords.rs deleted file mode 100644 index 1861a46..0000000 --- a/src/agents/util/treecoords.rs +++ /dev/null @@ -1,137 +0,0 @@ -use std::fmt::{Debug, Display}; - -use crate::board::TreeElement; - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum TreeDir { - Right, - Left, - This, -} - -#[derive(Clone, Copy)] -pub struct TreeCoords { - len: usize, - coords: [TreeDir; 4], - inversion: [bool; 4], -} - -impl Display for TreeCoords { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - if self.is_inverted() { - write!(f, "-")? - } else { - write!(f, "+")? - } - - for c in self.coords { - match c { - TreeDir::Left => write!(f, "L")?, - TreeDir::Right => write!(f, "R")?, - TreeDir::This => break, - } - } - - Ok(()) - } -} - -impl Debug for TreeCoords { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - Display::fmt(self, f) - } -} - -#[allow(dead_code)] -impl TreeCoords { - pub fn new() -> Self { - Self { - len: 0, - coords: [TreeDir::This; 4], - inversion: [false; 4], - } - } - - pub fn push(&mut self, dir: TreeDir, invert: bool) { - if self.len == 4 || dir == TreeDir::This { - return; - } - - self.coords[self.len] = dir; - self.inversion[self.len] = invert; - self.len += 1; - } - - pub fn pop(&mut self) -> Option<(TreeDir, bool)> { - if self.len == 0 { - return None; - } - - self.len -= 1; - let dir = self.coords[self.len]; - let inv = self.inversion[self.len]; - self.coords[self.len] = TreeDir::This; - self.inversion[self.len] = false; - Some((dir, inv)) - } - - pub fn is_inverted(&self) -> bool { - if self.len == 0 { - false - } else { - self.inversion[self.len - 1] - } - } - - pub fn get_from<'a>(&self, mut tree: &'a TreeElement) -> Option<&'a TreeElement> { - for i in 0..self.len { - match &self.coords[i] { - TreeDir::Left => { - if let Some(t) = tree.left() { - tree = t - } else { - return None; - } - } - - TreeDir::Right => { - if let Some(t) = tree.right() { - tree = t - } else { - return None; - } - } - - TreeDir::This => return Some(tree), - } - } - - Some(tree) - } - - pub fn get_from_mut<'a>(&self, mut tree: &'a mut TreeElement) -> Option<&'a mut TreeElement> { - for i in 0..self.len { - match &self.coords[i] { - TreeDir::Left => { - if let Some(t) = tree.left_mut() { - tree = t - } else { - return None; - } - } - - TreeDir::Right => { - if let Some(t) = tree.right_mut() { - tree = t - } else { - return None; - } - } - - TreeDir::This => return Some(tree), - } - } - - Some(tree) - } -} diff --git a/src/board/board.rs b/src/board/board.rs index 539090c..f4f550e 100644 --- a/src/board/board.rs +++ b/src/board/board.rs @@ -1,4 +1,6 @@ -use std::fmt::Display; +use anyhow::Result; +use itertools::Itertools; +use std::fmt::Write; use termion::color::{self, Color}; use super::{PlayerAction, TreeElement}; @@ -47,7 +49,8 @@ enum Token { #[derive(Clone)] pub struct Board { - board: [Option<(Symb, Player)>; 11], + board: [Option; 11], + placed_by: [Option; 11], /// Number of Nones in `board` free_spots: usize, @@ -56,30 +59,14 @@ pub struct Board { last_placed: Option, } -impl Display for Board { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - // Print board - for (i, o) in self.board.iter().enumerate() { - match o { - Some((symb, player)) => write!( - f, - "{}{}{}", - // If index matches last placed, draw symbol in red. - // If last_placed is None, this check will always fail - // since self.board.len is always greater than i. - if self.last_placed.unwrap_or(self.board.len()) == i { - color::Fg(&color::Red as &dyn Color) - } else { - color::Fg(player.color()) - }, - symb, - color::Fg(color::Reset) - )?, - - None => write!(f, "_")?, - } - } - Ok(()) +impl ToString for Board { + fn to_string(&self) -> String { + let mut s = String::new(); + s.extend( + self.board + .map(|x| x.map(|s| s.to_char().unwrap()).unwrap_or('_')), + ); + s } } @@ -89,34 +76,58 @@ impl Board { Self { free_spots: 11, board: Default::default(), + placed_by: Default::default(), last_placed: None, } } - pub fn iter(&self) -> impl Iterator> { - self.board.iter() + pub fn get_board(&self) -> &[Option; 11] { + &self.board } - pub fn get(&self, idx: usize) -> Option<&Option<(Symb, Player)>> { - self.board.get(idx) + pub fn get_board_mut(&mut self) -> &mut [Option; 11] { + &mut self.board } pub fn is_done(&self) -> bool { self.free_spots == 0 } + pub fn prettyprint(&self) -> Result { + let mut s = String::new(); + // Print board + for (i, (symb, p)) in self.board.iter().zip(self.placed_by.iter()).enumerate() { + match symb { + Some(symb) => write!( + s, + "{}{}{}", + // If index matches last placed, draw symbol in red. + // If last_placed is None, this check will always fail + // since self.board.len is always greater than i. + if self.last_placed.unwrap_or(self.board.len()) == i { + color::Fg(&color::Red as &dyn Color) + } else { + match p { + Some(player) => color::Fg(player.color()), + None => color::Fg(&color::Reset as &dyn Color), + } + }, + symb, + color::Fg(color::Reset) + )?, + + None => write!(s, "_")?, + } + } + Ok(s) + } + pub fn size(&self) -> usize { self.board.len() } pub fn contains(&self, s: Symb) -> bool { - for i in self.board.iter().flatten() { - if i.0 == s { - return true; - } - } - - false + self.board.iter().contains(&Some(s)) } /// Is the given action valid? @@ -137,14 +148,14 @@ impl Board { } let r = &self.board[action.pos + 1]; - if r.is_some_and(|(s, _)| s.is_op() && !s.is_minus()) { + if r.is_some_and(|s| s.is_op() && !s.is_minus()) { return false; } } Symb::Zero => { if action.pos != 0 { - let l = &self.board[action.pos - 1].map(|x| x.0); + let l = &self.board[action.pos - 1]; if l == &Some(Symb::Div) { return false; } @@ -156,8 +167,8 @@ impl Board { return false; } - let l = &self.board[action.pos - 1].map(|x| x.0); - let r = &self.board[action.pos + 1].map(|x| x.0); + let l = &self.board[action.pos - 1]; + let r = &self.board[action.pos + 1]; if action.symb == Symb::Div && r == &Some(Symb::Zero) { return false; @@ -184,7 +195,8 @@ impl Board { return false; } - self.board[action.pos] = Some((action.symb, player)); + self.board[action.pos] = Some(action.symb); + self.placed_by[action.pos] = Some(player); self.free_spots -= 1; self.last_placed = Some(action.pos); true @@ -195,7 +207,7 @@ impl Board { let mut is_neg = true; // if true, - is negative. if false, subtract. let mut current_num = String::new(); - for s in self.board.iter().map(|x| x.map(|(s, _)| s)) { + for s in self.board.iter() { match s { Some(Symb::Div) => { tokens.push(Token::Value(current_num.clone())); @@ -316,8 +328,18 @@ impl Board { self.to_tree().evaluate() } - /// Hacky method to parse a board from a string - pub fn from_string(s: &str, current_player: Player) -> Option { + pub fn from_board(board: [Option; 11]) -> Self { + let free_spots = board.iter().filter(|x| x.is_none()).count(); + Self { + board, + placed_by: Default::default(), + free_spots, + last_placed: None, + } + } + + /// Parse a board from a string + pub fn from_string(s: &str) -> Option { if s.len() != 11 { return None; } @@ -328,7 +350,7 @@ impl Board { if c == '_' { Some(None) } else { - Symb::from_char(c).map(|s| Some((s, current_player))) + Symb::from_char(c).map(|s| Some(s)) } }) .collect::>(); @@ -348,6 +370,7 @@ impl Board { Some(Self { board, + placed_by: Default::default(), free_spots, last_placed: None, })