From 4b86c031ed705f0beb60487ee06e0775adbb117b Mon Sep 17 00:00:00 2001 From: Joel Therrien Date: Wed, 6 Sep 2023 19:59:45 -0700 Subject: [PATCH] Add basic AI support (Rust only) --- src/board.rs | 24 +- src/player_interaction/ai.rs | 915 +++++++++++++++++++++++++++++++++++ 2 files changed, 926 insertions(+), 13 deletions(-) diff --git a/src/board.rs b/src/board.rs index 77ffc6f..1413328 100644 --- a/src/board.rs +++ b/src/board.rs @@ -9,12 +9,12 @@ use crate::dictionary::DictionaryImpl; #[derive(Clone, Copy)] -enum Direction { +pub enum Direction { Row, Column } impl Direction { - fn invert(&self) -> Self { + pub fn invert(&self) -> Self { match &self { Direction::Row => {Direction::Column} Direction::Column => {Direction::Row} @@ -22,7 +22,7 @@ impl Direction { } } -#[derive(Clone, Copy, Debug)] +#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)] pub struct Coordinates (pub u8, pub u8); impl Coordinates { @@ -47,20 +47,20 @@ impl Coordinates { } } - fn increment(&self, direction: Direction) -> Option{ + pub fn increment(&self, direction: Direction) -> Option{ self.add(direction, 1) } - fn decrement(&self, direction: Direction) -> Option{ + pub fn decrement(&self, direction: Direction) -> Option{ self.add(direction, -1) } - fn map_to_index(&self) -> usize { + pub fn map_to_index(&self) -> usize { (self.0 + GRID_LENGTH*self.1) as usize } } -#[derive(Debug, Copy, Clone, Serialize, Deserialize, Tsify)] +#[derive(Debug, Copy, Clone, Serialize, Deserialize, Tsify, PartialEq, Eq, Hash)] #[tsify(from_wasm_abi)] pub struct Letter { pub text: char, @@ -143,7 +143,7 @@ impl<'a> ToString for Word<'a> { impl <'a> Word<'a> { - fn calculate_score(&self) -> u32{ + pub fn calculate_score(&self) -> u32{ let mut multiplier = 1; let mut unmultiplied_score = 0; @@ -330,11 +330,9 @@ impl Board { let starting_row = *rows_played.iter().min().unwrap(); let starting_column = *columns_played.iter().min().unwrap(); - let mut starting_coords = Coordinates(starting_row, starting_column); + let starting_coords = Coordinates(starting_row, starting_column); let main_word = self.find_word_at_position(starting_coords, direction).unwrap(); - starting_coords = main_word.coords; - let mut words = Vec::new(); let mut observed_tiles_played = 0; @@ -389,7 +387,7 @@ impl Board { } } - fn find_word_at_position(&self, mut start_coords: Coordinates, direction: Direction) -> Option { + pub fn find_word_at_position(&self, mut start_coords: Coordinates, direction: Direction) -> Option { // let's see how far we can backtrack to the start of the word let mut times_moved = 0; loop { @@ -443,7 +441,7 @@ impl Board { pub fn receive_play(&mut self, play: Vec<(Letter, Coordinates)>) -> Result<(), String> { for (mut letter, coords) in play { { - let mut cell = match self.get_cell_mut(coords) { + let cell = match self.get_cell_mut(coords) { Ok(cell) => {cell} Err(e) => {return Err(e.to_string())} }; diff --git a/src/player_interaction/ai.rs b/src/player_interaction/ai.rs index bc03dea..fcd9b22 100644 --- a/src/player_interaction/ai.rs +++ b/src/player_interaction/ai.rs @@ -1,6 +1,921 @@ +use std::collections::{HashMap, HashSet}; +use rand::Rng; +use crate::board::{Board, CellType, Coordinates, Direction, Letter}; +use crate::constants::GRID_LENGTH; +use crate::dictionary::DictionaryImpl; +use crate::player_interaction::Tray; +const ALPHABET: [char; 26] = ['A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'K', 'L', 'M', 'N', 'O', 'P', 'Q', 'R', 'S', 'T', 'U', 'V', 'W', 'X', 'Y', 'Z']; +struct CoordinateLineMapper { + direction: Direction, + fixed: u8, +} + +impl CoordinateLineMapper { + fn line_to_coord(&self, variable: u8) -> Coordinates { + match self.direction { + Direction::Row => { + Coordinates(variable, self.fixed) + } + Direction::Column => { + Coordinates(self.fixed, variable) + } + } + } +} + +#[derive(Copy, Clone)] pub struct Difficulty { proportion: f64, randomness: f64, +} + +pub struct AI { + difficulty: Difficulty, + dictionary: HashSet, + substrings: HashSet, + + board_view: Board, + column_cross_tiles: CrossTiles, + row_cross_tiles: CrossTiles, +} + +type MoveMap = HashMap; +type CrossTiles = Vec>; + + +#[derive(Debug, Eq, PartialEq, Hash)] +pub struct CompleteMove { + moves: Vec, + score: u32, +} + +#[derive(Clone)] +struct MoveScoring { + main_scoring: u32, + cross_scoring: u32, + multiplier: u32, +} + +impl MoveScoring { + fn new() -> Self { + MoveScoring { + main_scoring: 0, + cross_scoring: 0, + multiplier: 1, + } + } +} + +#[derive(Copy, Clone, Debug, Eq, PartialEq, Hash)] +struct MoveComponent { + coordinates: Coordinates, + letter: Letter, +} + +impl AI { + + pub fn new(difficulty: Difficulty, dictionary: &DictionaryImpl) -> Self{ + let mut ai_dictionary = HashSet::new(); + let mut substrings = HashSet::new(); + + for (word, score) in dictionary.iter() { + if *score >= difficulty.proportion { // TODO - may need to reverse + + ai_dictionary.insert(word.clone()); + substrings.insert(word.clone()); + + for i in 0..word.len() { + for j in i+1..word.len() { + substrings.insert(word[i..j].to_string()); + } + } + + + } + } + + let board_view = Board::new(); + let mut column_cross_tiles = CrossTiles::with_capacity((GRID_LENGTH * GRID_LENGTH) as usize); + let mut row_cross_tiles = CrossTiles::with_capacity((GRID_LENGTH * GRID_LENGTH) as usize); + + board_view.cells.iter().for_each(|_| { + column_cross_tiles.push(None); + row_cross_tiles.push(None); + }); + + AI { + difficulty, + dictionary: ai_dictionary, + substrings, + + board_view, + column_cross_tiles, + row_cross_tiles, + } + } + + pub fn find_best_move(&mut self, tray: &Tray, grid: &Board, rng: &mut R) -> Option{ + let move_set = self.find_all_moves(tray, grid); + + let mut best_move: Option<(CompleteMove, f64)> = None; + + for possible_move in move_set { + let move_score = + if self.difficulty.randomness > 0.0 { + (1.0 - self.difficulty.randomness) * (possible_move.score as f64) + self.difficulty.randomness * rng.gen_range(0.0..1.0) + } else { + possible_move.score as f64 + }; + + match &best_move { + None => { + best_move = Some((possible_move, move_score)); + } + Some((_, current_score)) => { + if move_score > *current_score { + best_move = Some((possible_move, move_score)); + } + } + } + } + + return match best_move { + None => {None} + Some((best_move, _)) => {Some(best_move)} + }; + + } + + fn find_all_moves(&mut self, tray: &Tray, grid: &Board) -> HashSet { + self.update_state(grid); + + let mut all_moves: HashSet = HashSet::new(); + + self.find_moves_in_direction(tray, Direction::Row, &mut all_moves); + self.find_moves_in_direction(tray, Direction::Column, &mut all_moves); + + all_moves + } + + fn find_moves_in_direction( + &self, + tray: &Tray, + direction: Direction, + all_moves: &mut HashSet) { + + // If you're building a word in one direction, you need to form valid words in the cross-direction + let cross_tiles = match direction { + Direction::Column => {&self.row_cross_tiles} + Direction::Row => {&self.column_cross_tiles} + }; + + let tray_letters = tray.letters.iter() + .filter(|letter| letter.is_some()) + .map(|letter| letter.unwrap()) + .collect::>(); + + for k in 0..GRID_LENGTH { + let mut line_letters = Vec::with_capacity(GRID_LENGTH as usize); + let mut line_cross_letters = Vec::with_capacity(GRID_LENGTH as usize); + + let coord_mapper = CoordinateLineMapper { + direction, + fixed: k + }; + + for p in 0..GRID_LENGTH { + let coords = coord_mapper.line_to_coord(p); + line_letters.push(self.board_view.get_cell(coords).unwrap().value); + line_cross_letters.push(cross_tiles.get(coords.map_to_index()).unwrap()); + } + + for l in 0..GRID_LENGTH { + let coords = coord_mapper.line_to_coord(l); + + let is_anchored = check_if_anchored(&self.board_view, coords, Direction::Row) || + check_if_anchored(&self.board_view, coords, Direction::Column); + + if is_anchored && + line_letters.get(l as usize).unwrap().is_none() // it's duplicate work to check here when we'll already check at either free side + { + + self.evaluate_spot_heading_left( + &line_letters, + &line_cross_letters, + l as i8, + &coord_mapper, + tray_letters.clone(), + &Vec::new(), + 1, + &MoveScoring::new(), + + all_moves + + ); + + } + + + } + } + + } + + fn evaluate_spot_heading_left( + &self, + line_letters: &Vec>, + line_cross_letters: &Vec<&Option>, + line_index: i8, + + coord_mapper: &CoordinateLineMapper, + available_letters: Vec, + current_play: &Vec, + min_length: usize, + current_points: &MoveScoring, + + all_moves: &mut HashSet + + ) { + + if line_index < 0 || line_index >= GRID_LENGTH as i8 { + return; + } + + // we want to keep heading left until we get to a blank space + match line_letters.get(line_index as usize).unwrap() { + Some(_) => { + // there's a letter here; need to take a step left if we can + + if !(line_index >= 1 && + line_letters.get((line_index-1) as usize).unwrap().is_some() && + min_length == 1 + ) { + // if-statement is basically saying that if we're at the start of the process (min_length==1) and there's a word still to our left, + // just stop. Other versions of the for-loops that call this function will have picked up that case. + self.evaluate_spot_heading_left( + line_letters, + line_cross_letters, + line_index - 1, + coord_mapper, + available_letters, + current_play, + min_length, + current_points, + all_moves + ); + + } + } + None => { + // there's no word to the left so we can start trying to play + for letter in available_letters.iter() { + self.evaluate_letter_at_spot( + line_letters, + line_cross_letters, + line_index, + current_play, + min_length, + current_points, + coord_mapper, + + &available_letters, + letter.clone(), + all_moves + ); + } + + // after trying those plays, maybe we can try a longer word by moving another to the left? + self.evaluate_spot_heading_left( + line_letters, + line_cross_letters, + line_index - 1, + coord_mapper, + available_letters, + current_play, + min_length + 1, + current_points, + all_moves + ); + } + } + + + } + + fn evaluate_letter_at_spot( + &self, + line_letters: &Vec>, + line_cross_letters: &Vec<&Option>, + line_index: i8, + current_play: &Vec, + min_length: usize, + current_points: &MoveScoring, + coord_mapper: &CoordinateLineMapper, + + + available_letters: &Vec, + letter: Letter, + all_moves: &mut HashSet + + ) { + + if letter.is_blank { + // need to loop through alphabet + for alpha in ALPHABET { + let mut letter = letter.clone(); + letter.text = alpha; + + self.evaluate_non_blank_letter_at_spot( + line_letters, + line_cross_letters, + line_index, + current_play.clone(), + min_length, + current_points, + coord_mapper, + available_letters, + letter, + all_moves + + ); + } + } else { + self.evaluate_non_blank_letter_at_spot( + line_letters, + line_cross_letters, + line_index, + current_play.clone(), + min_length, + current_points, + coord_mapper, + available_letters, + letter.clone(), + all_moves + ); + } + + } + + fn evaluate_non_blank_letter_at_spot( + &self, + line_letters: &Vec>, + line_cross_letters: &Vec<&Option>, + line_index: i8, + mut current_play: Vec, + min_length: usize, + current_points: &MoveScoring, + + + coord_mapper: &CoordinateLineMapper, + available_letters: &Vec, + letter: Letter, + + all_moves: &mut HashSet, + + ) { + + assert!(!letter.is_blank); // we should have handled blanks in evaluate_letter_at_spot + + // let's now assign the letter to this spot + let mut line_letters = line_letters.clone(); + *line_letters.get_mut(line_index as usize).unwrap() = Some(letter); + current_play.push(MoveComponent { + coordinates: coord_mapper.line_to_coord(line_index as u8), + letter, + }); + + // Check if we form at least part of a valid word, otherwise we can just skip forward + let word = find_word_on_line(&line_letters, line_index); + if !self.substrings.contains(&word) { + return; + } + + let cross_letters = line_cross_letters.get(line_index as usize).unwrap(); + + // let's first verify that `letter` makes a valid cross-word. + match cross_letters { + None => {} + Some(map) => { + if !map.contains_key(&letter.text) { + return; + } + } + } + + // Time to start scoring what this move might look like + + // making copy + let mut current_points = current_points.clone(); + let cell_type = self.board_view.get_cell(coord_mapper.line_to_coord(line_index as u8)).unwrap().cell_type; + + // first we score cross letters + if let Some(map) = cross_letters { + let mut cross_word_score: u32 = 0; + let mut cross_letter_score = *map.get(&letter.text).unwrap(); // I can unwrap because I earlier confirmed that letter is in the map + + match cell_type { + CellType::DoubleLetter => { + cross_letter_score *= 2; + } + CellType::TripleLetter => { + cross_letter_score *= 3; + } + _ => {} // no letter multiplier + }; + + cross_word_score += cross_letter_score; + + // now we see if we multiply the word + match cell_type { + CellType::DoubleWord | CellType::Start => { + cross_word_score *= 2; + } + CellType::TripleWord => { + cross_word_score *= 3; + } + _ => {} + } + + current_points.cross_scoring += cross_word_score; + } + + // now we score on the main axis + match cell_type { + CellType::DoubleWord | CellType::Start => { + current_points.multiplier *= 2; + current_points.main_scoring += letter.points; + } + CellType::TripleWord => { + current_points.multiplier *= 3; + current_points.main_scoring += letter.points; + } + CellType::Normal => { + current_points.main_scoring += letter.points; + } + CellType::DoubleLetter => { + current_points.main_scoring += letter.points * 2; + } + CellType::TripleLetter => { + current_points.main_scoring += letter.points * 3;} + }; + + // finally, while we know that we're in a valid substring we should check if this is a valid word or not + // so that we can possible submit our move + if word.len() >= min_length && self.dictionary.contains(&word) { + all_moves.insert(CompleteMove { + moves: current_play.clone(), + score: current_points.cross_scoring + current_points.main_scoring*current_points.multiplier, + }); + } + + // remove the first instance of letter from available_letters + let mut new_available_letters = Vec::with_capacity(available_letters.len() - 1); + let mut skipped_one = false; + + available_letters.iter() + .for_each(|in_tray| { + if skipped_one || *in_tray != letter { + new_available_letters.push(*in_tray); + } else { + skipped_one = true; + } + }); + + self.evaluate_spot_heading_right( + &line_letters, + line_cross_letters, + line_index+1, + current_play, + min_length, + ¤t_points, + + coord_mapper, + &new_available_letters, + + all_moves + ); + } + + fn evaluate_spot_heading_right( + &self, + line_letters: &Vec>, + line_cross_letters: &Vec<&Option>, + line_index: i8, + current_play: Vec, + min_length: usize, + current_points: &MoveScoring, + + + coord_mapper: &CoordinateLineMapper, + available_letters: &Vec, + + all_moves: &mut HashSet, + ) { + + // out-of-bounds check + if line_index < 0 || line_index >= GRID_LENGTH as i8 { + return; + } + + match line_letters.get(line_index as usize).unwrap() { + Some(letter) => { + // there's a letter already here so let's move again to the right + let mut current_points = current_points.clone(); + current_points.main_scoring += letter.points; + self.evaluate_spot_heading_right( + line_letters, + line_cross_letters, + line_index + 1, + current_play, + min_length, + ¤t_points, + coord_mapper, + available_letters, + all_moves + ); + } + None => { + // there's a blank spot, so let's loop through every available letter and evaluate at this spot + for letter in available_letters { + self.evaluate_letter_at_spot( + line_letters, + line_cross_letters, + line_index, + ¤t_play.clone(), + min_length, + current_points, + coord_mapper, + available_letters, + + letter.clone(), + all_moves + + ) + } + } + } + } + + fn update_state(&mut self, new: &Board) { + let mut updated_rows = HashSet::new(); + let mut updated_columns = HashSet::new(); + + for (x, y) in new.cells.iter().zip(self.board_view.cells.iter_mut()) { + if !(x.value.eq(&y.value)) { + y.value = x.value; + let coords = x.coordinates; + updated_rows.insert(coords.1); + updated_columns.insert(coords.0); + } + } + + let mut check_for_valid_moves = |coords: Coordinates| { + + let cell = new.get_cell(coords).unwrap(); + if cell.value.is_none() { + let valid_row_moves = self.get_letters_that_make_words(new, Direction::Row, coords); + let valid_column_moves = self.get_letters_that_make_words(new, Direction::Column, coords); + + let existing_row_moves = self.row_cross_tiles.get_mut(coords.map_to_index()).unwrap(); + *existing_row_moves = valid_row_moves; + + let existing_column_moves = self.column_cross_tiles.get_mut(coords.map_to_index()).unwrap(); + *existing_column_moves = valid_column_moves; + } + }; + + updated_rows.iter().for_each(|&j| { + for i in 0..GRID_LENGTH { + let coords = Coordinates(i, j); + check_for_valid_moves(coords); + } + }); + + updated_columns.iter().for_each(|&i| { + for j in 0..GRID_LENGTH { + let coords = Coordinates(i, j); + check_for_valid_moves(coords); + } + }); + + } + + fn get_letters_that_make_words(&self, new: &Board, direction: Direction, coords: Coordinates) -> Option { + let is_anchored = check_if_anchored(new, coords, direction); + if !is_anchored { + return None; + } + + let mut new_map = MoveMap::new(); + + let mut copy_grid = new.clone(); + for letter in ALPHABET { + let cell = copy_grid.get_cell_mut(coords).unwrap(); + cell.value = Some(Letter { + text: letter, + points: 0, // points are accounted for later + ephemeral: false, // so that tile bonuses are ignored + is_blank: false, + }); + + let word = copy_grid.find_word_at_position(coords, direction); + match word { + None => {} + Some(word) => { + if self.dictionary.contains(&word.to_string()) { + let score = word.calculate_score(); + new_map.insert(letter, score); + } + } + } + } + + Some(new_map) + } + +} + +fn check_if_anchored(grid: &Board, coords: Coordinates, direction: Direction) -> bool{ + + // Middle tile is always considered anchored + if coords.0 == GRID_LENGTH / 2 && coords.1 == GRID_LENGTH / 2 { + return true; + } + + let has_letter = |alt_coord: Option| -> bool { + match alt_coord { + None => {false} + Some(alt_coord) => { + let cell = grid.get_cell(alt_coord).unwrap(); + cell.value.is_some() + } + } + }; + + return has_letter(coords.increment(direction)) || has_letter(coords.decrement(direction)); +} + +fn find_word_on_line(line_letters: &Vec>, line_index: i8) -> String { + let mut start_word = line_index; + let mut end_word = line_index; + + // start moving left until we hit an empty tile + while start_word >= 0 { + if start_word < 1 { + break; + } + if line_letters.get((start_word - 1) as usize).unwrap().is_none() { + break; + } + start_word -= 1; + } + + // start moving right until we hit an empty tile + while end_word < GRID_LENGTH as i8 { + if end_word + 1 >= GRID_LENGTH as i8 { + break; + } + if line_letters.get((end_word + 1) as usize).unwrap().is_none() { + break; + } + end_word += 1; + } + + let mut str = String::new(); + line_letters[(start_word as usize)..((end_word + 1) as usize)] + .iter().for_each(|letter| { + str.push(letter.unwrap().text); + }); + + str +} + +#[cfg(test)] +mod tests { + use rand::rngs::SmallRng; + use rand::SeedableRng; + use super::*; + + fn set_cell(board: &mut Board, x: u8, y: u8, letter: char, points: u32) { + let cell = board.get_cell_mut(Coordinates(x, y)).unwrap(); + cell.value = Some(Letter { + text: letter, + points, + ephemeral: false, + is_blank: false, + }); + } + + #[test] + fn test_dictionary() { + let difficulty = Difficulty { + proportion: 0.8, // restrict yourself to words with this proportion OR HIGHER + randomness: 0.0, + }; + + let mut dictionary = DictionaryImpl::new(); + dictionary.insert("APP".to_string(), 0.5); + dictionary.insert("APPLE".to_string(), 0.9); + + let ai = AI::new(difficulty, &dictionary); + + assert_eq!(1, ai.dictionary.len()); + assert!(ai.dictionary.contains("APPLE")); + } + + #[test] + fn test_blank_board() { + let board = Board::new(); + + let difficulty = Difficulty { + proportion: 0.8, // restrict yourself to words with this proportion OR HIGHER + randomness: 0.0, + }; + + let mut dictionary = DictionaryImpl::new(); + dictionary.insert("APP".to_string(), 0.5); + dictionary.insert("APPLE".to_string(), 0.9); + + let mut tray = Tray::new(7); + tray.letters[0] = Some(Letter{ + text: 'A', + points: 5, + ephemeral: false, + is_blank: false, + }); + tray.letters[1] = Some(Letter{ + text: 'P', + points: 4, + ephemeral: false, + is_blank: false, + }); + tray.letters[2] = Some(Letter{ + text: 'P', + points: 4, + ephemeral: false, + is_blank: false, + }); + tray.letters[3] = Some(Letter{ + text: 'L', + points: 3, + ephemeral: false, + is_blank: false, + }); + tray.letters[4] = Some(Letter{ + text: 'E', + points: 4, + ephemeral: false, + is_blank: false, + }); + + let mut ai = AI::new(difficulty, &dictionary); + + //let rng = SmallRng::seed_from_u64(123); + + let moves = ai.find_all_moves(&tray, &board); + + println!("Moves are {:?}", moves); + + assert_eq!(moves.len(), 10); + + } + + #[test] + fn test_cross_words() { + let mut board = Board::new(); + + set_cell(&mut board, 7, 7, 'B', 5); + set_cell(&mut board, 7, 8, 'O', 1); + set_cell(&mut board, 7, 9, 'A', 1); + set_cell(&mut board, 7, 10, 'T', 1); + + + + let difficulty = Difficulty { + proportion: 0.0, // restrict yourself to words with this proportion OR HIGHER + randomness: 0.0, + }; + + let mut dictionary = DictionaryImpl::new(); + dictionary.insert("BOATS".to_string(), 0.5); + dictionary.insert("SLAM".to_string(), 0.9); + + let mut tray = Tray::new(7); + tray.letters[0] = Some(Letter{ + text: 'S', + points: 1, + ephemeral: false, + is_blank: false, + }); + tray.letters[1] = Some(Letter{ + text: 'L', + points: 1, + ephemeral: false, + is_blank: false, + }); + tray.letters[2] = Some(Letter{ + text: 'A', + points: 1, + ephemeral: false, + is_blank: false, + }); + tray.letters[3] = Some(Letter{ + text: 'M', + points: 3, + ephemeral: false, + is_blank: false, + }); + + let mut ai = AI::new(difficulty, &dictionary); + + ai.update_state(&board); + + let end_of_boat = ai.column_cross_tiles.get(Coordinates(7, 11).map_to_index()).unwrap(); + assert!(end_of_boat.is_some()); + assert_eq!(end_of_boat.as_ref().unwrap().len(), 1); + + //let rng = SmallRng::seed_from_u64(123); + + let moves = ai.find_all_moves(&tray, &board); + + println!("Moves are {:?}", moves); + + + // 2 possible moves - + // 1. put 'S' at the end of 'BOAT' and form words 'SLAM' and 'BOATS' + // 2. Put 'S' at end of 'BOAT' + // 3. Put 'SL' to left of 'A' in 'BOAT' and then 'M' to right of it + assert_eq!(moves.len(), 3); + + } + + #[test] + fn test_cross_tiles() { + let mut board = Board::new(); + + set_cell(&mut board, 7, 7, 'B', 5); + + let difficulty = Difficulty { + proportion: 0.0, // restrict yourself to words with this proportion OR HIGHER + randomness: 0.0, + }; + + let mut dictionary = DictionaryImpl::new(); + dictionary.insert("AB".to_string(), 0.5); + + let mut ai = AI::new(difficulty, &dictionary); + ai.update_state(&board); + + let above_cell_coords = Coordinates(7, 6); + let left_cell_cords = Coordinates(6, 7); + + let row_cross_tiles = ai.row_cross_tiles.get(left_cell_cords.map_to_index()).unwrap(); + let column_cross_tiles = ai.column_cross_tiles.get(above_cell_coords.map_to_index()).unwrap(); + + assert_eq!(row_cross_tiles.as_ref().unwrap().len(), 1); + assert_eq!(column_cross_tiles.as_ref().unwrap().len(), 1); + + let far_off_tiles = ai.row_cross_tiles.get(0).unwrap(); + assert!(far_off_tiles.is_none()); + + } + + #[test] + fn test_valid_moves() { + let mut board = Board::new(); + + set_cell(&mut board, 7-3, 7+3, 'Z', 1); + set_cell(&mut board, 6-3, 8+3, 'A', 1); + set_cell(&mut board, 7-3, 8+3, 'A', 1); + set_cell(&mut board, 7-3, 9+3, 'Z', 1); + + let difficulty = Difficulty { + proportion: 0.0, // restrict yourself to words with this proportion OR HIGHER + randomness: 0.0, + }; + + let mut dictionary = DictionaryImpl::new(); + dictionary.insert("AA".to_string(), 0.5); + + let mut tray = Tray::new(7); + tray.letters[0] = Some(Letter{ + text: 'A', + points: 1, + ephemeral: false, + is_blank: false, + }); + + let mut ai = AI::new(difficulty, &dictionary); + + let moves = ai.find_all_moves(&tray, &board); + + println!("Moves are {:?}", moves); + + assert!(moves.is_empty()); + } + + + } \ No newline at end of file