Add rudimentry but functional AI integration into UI

Also fixed some bugs that could cause AI process to either
produce invalid words or crash.
This commit is contained in:
Joel Therrien 2023-09-12 22:13:36 -07:00
parent 9b22f1301d
commit 8fd250170b
6 changed files with 194 additions and 48 deletions

View file

@ -101,7 +101,7 @@ impl Letter {
} }
pub fn partial_match(&self, other: &Letter) -> bool { pub fn partial_match(&self, other: &Letter) -> bool {
self == other || (self.is_blank == other.is_blank && self.points == other.points) self == other || (self.is_blank && other.is_blank && self.points == other.points)
} }
} }

View file

@ -41,6 +41,35 @@ pub struct PlayedTile {
pub character: Option<char>, // we only set this if PlayedTile is a blank pub character: Option<char>, // we only set this if PlayedTile is a blank
} }
impl PlayedTile {
pub fn convert_tray(tray_tile_locations: &Vec<Option<PlayedTile>>, tray: &Tray) -> Vec<(Letter, Coordinates)> {
let mut played_letters: Vec<(Letter, Coordinates)> = Vec::new();
for (i, played_tile) in tray_tile_locations.iter().enumerate() {
if played_tile.is_some() {
let played_tile = played_tile.unwrap();
let mut letter: Letter = tray.letters.get(i).unwrap().unwrap();
let coord = Coordinates::new_from_index(played_tile.index);
if letter.is_blank {
match played_tile.character {
None => {
panic!("You can't play a blank character without providing a letter value")
}
Some(x) => {
// TODO - check that x is a valid alphabet letter
letter.text = x;
}
}
}
played_letters.push((letter, coord));
}
}
played_letters
}
}
#[derive(Debug, Serialize, Deserialize, Tsify)] #[derive(Debug, Serialize, Deserialize, Tsify)]
#[tsify(from_wasm_abi)] #[tsify(from_wasm_abi)]
pub struct WordResult { pub struct WordResult {
@ -177,27 +206,10 @@ impl Game {
let mut board_instance = self.get_board().clone(); let mut board_instance = self.get_board().clone();
let mut tray = self.player_states.get_tray(&player).unwrap().clone(); let mut tray = self.player_states.get_tray(&player).unwrap().clone();
let mut played_letters: Vec<(Letter, Coordinates)> = Vec::new(); let played_letters: Vec<(Letter, Coordinates)> = PlayedTile::convert_tray(&tray_tile_locations, &tray);
for (i, played_tile) in tray_tile_locations.iter().enumerate() { for (i, played_tile) in tray_tile_locations.iter().enumerate() {
if played_tile.is_some() { if played_tile.is_some() {
let played_tile = played_tile.unwrap();
let mut letter: Letter = tray.letters.get(i).unwrap().unwrap();
*tray.letters.get_mut(i).unwrap() = None; *tray.letters.get_mut(i).unwrap() = None;
let coord = Coordinates::new_from_index(played_tile.index);
if letter.is_blank {
match played_tile.character {
None => {
panic!("You can't play a blank character without providing a letter value")
}
Some(x) => {
// TODO - check that x is a valid alphabet letter
letter.text = x;
}
}
}
played_letters.push((letter, coord));
} }
} }
@ -283,14 +295,15 @@ impl Game {
self.player_states.get_player_name_by_turn_id(self.turn_order).to_string() self.player_states.get_player_name_by_turn_id(self.turn_order).to_string()
} }
pub fn advance_turn(&mut self) -> TurnAdvanceResult { pub fn advance_turn(&mut self) -> Result<TurnAdvanceResult, String> {
let current_player = self.current_player_name(); let current_player = self.current_player_name();
let state = self.player_states.get_player_state_mut(&current_player).unwrap(); let state = self.player_states.get_player_state_mut(&current_player).ok_or("There should be a player available")?;
if let Player::AI {object, .. } = &mut state.player { if let Player::AI {object, .. } = &mut state.player {
let tray = &mut state.tray; let tray = &mut state.tray;
let best_move = object.find_best_move(tray, &self.board, &mut self.rng); let best_move = object.find_best_move(tray, &self.board, &mut self.rng);
//let best_move: Option<CompleteMove> = None;
match best_move { match best_move {
None => { None => {
// no available moves; exchange all tiles // no available moves; exchange all tiles
@ -309,31 +322,32 @@ impl Game {
} }
if self.tile_pool.is_empty(){ if self.tile_pool.is_empty(){
TurnAdvanceResult::AIMove { Ok(TurnAdvanceResult::AIMove {
name: current_player, name: current_player,
action: TurnAction::Pass, action: TurnAction::Pass,
} })
} else { } else {
self.exchange_tiles(to_exchange).unwrap(); self.exchange_tiles(to_exchange)?;
TurnAdvanceResult::AIMove { Ok(TurnAdvanceResult::AIMove {
name: current_player, name: current_player,
action: TurnAction::ExchangeTiles(tiles_exchanged), action: TurnAction::ExchangeTiles{tiles_exchanged},
} })
} }
} }
Some(best_move) => { Some(best_move) => {
let play = best_move.convert_to_play(tray); let play = best_move.convert_to_play(tray);
let score_result = self.receive_play(play, true).unwrap(); let score_result = self.receive_play(play, true)?;
TurnAdvanceResult::AIMove { Ok(TurnAdvanceResult::AIMove {
name: current_player, name: current_player,
action: TurnAction::PlayTiles(score_result), action: TurnAction::PlayTiles{result: score_result},
} })
} }
} }
} else { } else {
TurnAdvanceResult::HumanInputRequired(self.current_player_name()) Ok(TurnAdvanceResult::HumanInputRequired{name: self.current_player_name()})
} }
@ -341,20 +355,58 @@ impl Game {
} }
#[derive(Deserialize, Tsify, Copy, Clone)] #[derive(Serialize, Deserialize, Tsify, Debug)]
#[tsify(from_wasm_abi)] #[tsify(from_wasm_abi)]
#[serde(tag = "type")]
pub enum TurnAction { pub enum TurnAction {
Pass, Pass,
ExchangeTiles(usize), ExchangeTiles{
PlayTiles(ScoreResult), tiles_exchanged: usize
},
PlayTiles{
result: ScoreResult
},
} }
#[derive(Deserialize, Tsify, Copy, Clone)] #[derive(Serialize, Deserialize, Tsify, Debug)]
#[tsify(from_wasm_abi)] #[tsify(from_wasm_abi)]
#[serde(tag = "type")]
pub enum TurnAdvanceResult { pub enum TurnAdvanceResult {
HumanInputRequired(String), HumanInputRequired{
name: String
},
AIMove{ AIMove{
name: String, name: String,
action: TurnAction, action: TurnAction,
} }
} }
#[cfg(test)]
mod tests {
use std::fs;
use crate::game::Game;
use crate::player_interaction::ai::Difficulty;
#[test]
fn test_game() {
let seed = 124;
let dictionary_path = "resources/dictionary.csv";
let dictionary_string = fs::read_to_string(dictionary_path).unwrap();
let mut game = Game::new(seed, &dictionary_string, vec!["Player".to_string()], vec![Difficulty{proportion: 0.5, randomness: 0.0}]);
let current_player = game.current_player_name();
println!("Current player is {current_player}");
assert_eq!(current_player, "Player");
game.increment_turn();
assert_eq!(game.current_player_name(), "AI 0");
let result = game.advance_turn();
println!("AI move is {result:?}");
}
}

View file

@ -31,8 +31,8 @@ impl CoordinateLineMapper {
#[derive(Copy, Clone, Serialize, Deserialize, Tsify)] #[derive(Copy, Clone, Serialize, Deserialize, Tsify)]
#[tsify(from_wasm_abi)] #[tsify(from_wasm_abi)]
pub struct Difficulty { pub struct Difficulty {
proportion: f64, pub proportion: f64,
randomness: f64, pub randomness: f64,
} }
pub struct AI { pub struct AI {
@ -92,7 +92,9 @@ impl CompleteMove {
None => {} None => {}
} }
} }
assert!(found_match); if !found_match {
played_tiles.push(None);
}
} }
} }
}); });
@ -174,7 +176,7 @@ impl AI {
for possible_move in move_set { for possible_move in move_set {
let move_score = let move_score =
if self.difficulty.randomness > 0.0 { 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) (1.0 - self.difficulty.randomness) * (possible_move.score as f64) + self.difficulty.randomness * rng.gen_range(0.0..1.0)
} else { } else {
possible_move.score as f64 possible_move.score as f64
}; };
@ -512,10 +514,11 @@ impl AI {
// finally, while we know that we're in a valid substring we should check if this is a valid word or not // 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 // so that we can possible submit our move
if word.len() >= min_length && self.dictionary.contains(&word) { if word.len() >= min_length && self.dictionary.contains(&word) {
all_moves.insert(CompleteMove { let new_move = CompleteMove {
moves: current_play.clone(), moves: current_play.clone(),
score: current_points.cross_scoring + current_points.main_scoring*current_points.multiplier, score: current_points.cross_scoring + current_points.main_scoring*current_points.multiplier,
}); };
all_moves.insert(new_move);
} }
// remove the first instance of letter from available_letters // remove the first instance of letter from available_letters
@ -746,6 +749,7 @@ fn find_word_on_line(line_letters: &Vec<Option<Letter>>, line_index: i8) -> Stri
mod tests { mod tests {
use rand::rngs::SmallRng; use rand::rngs::SmallRng;
use rand::SeedableRng; use rand::SeedableRng;
use crate::dictionary::Dictionary;
use super::*; use super::*;
fn set_cell(board: &mut Board, x: u8, y: u8, letter: char, points: u32) { fn set_cell(board: &mut Board, x: u8, y: u8, letter: char, points: u32) {
@ -984,6 +988,67 @@ mod tests {
assert!(moves.is_empty()); assert!(moves.is_empty());
} }
#[test]
fn test_starting_move() {
let mut board = Board::new();
let difficulty = Difficulty{proportion: 0.1, randomness: 0.0};
let mut dictionary = DictionaryImpl::new();
dictionary.insert("TWEETED".to_string(), 0.2);
let mut tray = Tray::new(7);
tray.letters[0] = Some(Letter{
text: 'I',
points: 1,
ephemeral: false,
is_blank: false,
});
tray.letters[1] = Some(Letter{
text: 'R',
points: 1,
ephemeral: false,
is_blank: false,
});
tray.letters[2] = Some(Letter{
text: 'W',
points: 1,
ephemeral: false,
is_blank: false,
});
tray.letters[3] = Some(Letter{
text: 'I',
points: 1,
ephemeral: false,
is_blank: false,
});
tray.letters[4] = Some(Letter{
text: 'T',
points: 1,
ephemeral: false,
is_blank: false,
});
tray.letters[5] = Some(Letter{
text: 'E',
points: 1,
ephemeral: false,
is_blank: false,
});
tray.letters[6] = Some(Letter{
text: 'D',
points: 1,
ephemeral: false,
is_blank: false,
});
let mut ai = AI::new(difficulty, &dictionary);
let all_moves = ai.find_all_moves(&tray, &board);
println!("Available moves are {:?}", all_moves);
assert!(all_moves.is_empty());
}
} }

View file

@ -136,5 +136,19 @@ impl GameWasm {
pub fn skip_turn(&mut self) {self.0.increment_turn()} pub fn skip_turn(&mut self) {self.0.increment_turn()}
pub fn advance_turn(&mut self) -> Result<JsValue, Error> {
let result = self.0.advance_turn();
match result {
Ok(x) => {
Ok(serde_wasm_bindgen::to_value(&x)?)
},
Err(e) => {
Ok(serde_wasm_bindgen::to_value(&e)?)
}
}
}
} }

View file

@ -6,7 +6,7 @@ import {
PlayedTile, PlayedTile,
PlayerAndScore, PlayerAndScore,
ScoreResult, ScoreResult,
Tray Tray, TurnAdvanceResult
} from "word_grid"; } from "word_grid";
import { import {
LocationType, LocationType,
@ -124,7 +124,7 @@ export function Game(props: {
} }
}) })
const result: MyResult<Tray | string> = props.wasm.exchange_tiles("Player", selectedArray); const result: MyResult<Tray | string> = props.wasm.exchange_tiles(selectedArray);
console.log({result}); console.log({result});
if(result.response_type === "ERR") { if(result.response_type === "ERR") {
@ -181,7 +181,7 @@ export function Game(props: {
return null; return null;
}); });
const result: MyResult<ScoreResult | string> = props.wasm.receive_play("Player", playedTiles, confirmedScorePoints > -1); const result: MyResult<ScoreResult | string> = props.wasm.receive_play(playedTiles, confirmedScorePoints > -1);
console.log({result}); console.log({result});
if(result.response_type === "ERR") { if(result.response_type === "ERR") {
@ -223,6 +223,16 @@ export function Game(props: {
<button onClick={() => { <button onClick={() => {
trayDispatch({action: TileDispatchActionType.RETURN}); trayDispatch({action: TileDispatchActionType.RETURN});
}}>Return Tiles</button> }}>Return Tiles</button>
<button onClick={() => {
props.wasm.skip_turn();
logDispatch(<div><em>Player skipped their turn</em></div>);
setTurnCount(turnCount + 1);
}}>Skip Turn</button>
<button onClick={() => {
const result: TurnAdvanceResult = props.wasm.advance_turn();
console.info({result});
setTurnCount(turnCount + 1);
}}>Run AI</button>
</>; </>;
} }

View file

@ -1,6 +1,6 @@
import * as React from "react"; import * as React from "react";
import {useState} from "react"; import {useState} from "react";
import {GameWasm} from 'word_grid'; import {Difficulty, GameWasm} from 'word_grid';
import {Settings} from "./utils"; import {Settings} from "./utils";
import {Game} from "./Game"; import {Game} from "./Game";
@ -15,6 +15,7 @@ export function Menu(props: {settings: Settings, dictionary_text: string}) {
// aiRandomness = log(1 + x*(n-1))/log(n) when x, the user input, ranges between 0 and 1 // aiRandomness = log(1 + x*(n-1))/log(n) when x, the user input, ranges between 0 and 1
const logBase: number = 10000; const logBase: number = 10000;
const processedAIRandomness = Math.log(1 + (logBase - 1)*aiRandomness/100) / Math.log(logBase); const processedAIRandomness = Math.log(1 + (logBase - 1)*aiRandomness/100) / Math.log(logBase);
const processedProportionDictionary = 1.0 - proportionDictionary / 100;
if (game == null) { if (game == null) {
@ -64,7 +65,11 @@ export function Menu(props: {settings: Settings, dictionary_text: string}) {
<div className="selection-buttons"> <div className="selection-buttons">
<button onClick={() => { <button onClick={() => {
const seed = new Date().getTime(); const seed = new Date().getTime();
const game_wasm = new GameWasm(BigInt(seed), props.dictionary_text); const difficulty: Difficulty = {
proportion: processedProportionDictionary,
randomness: processedAIRandomness,
};
const game_wasm = new GameWasm(BigInt(seed), props.dictionary_text, difficulty);
const game = <Game settings={props.settings} wasm={game_wasm} key={seed} end_game_fn={() => setGame(null)}/> const game = <Game settings={props.settings} wasm={game_wasm} key={seed} end_game_fn={() => setGame(null)}/>
setGame(game); setGame(game);
}}>New Game</button> }}>New Game</button>