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 {
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
}
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)]
#[tsify(from_wasm_abi)]
pub struct WordResult {
@ -177,27 +206,10 @@ impl Game {
let mut board_instance = self.get_board().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() {
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;
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()
}
pub fn advance_turn(&mut self) -> TurnAdvanceResult {
pub fn advance_turn(&mut self) -> Result<TurnAdvanceResult, String> {
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 {
let tray = &mut state.tray;
let best_move = object.find_best_move(tray, &self.board, &mut self.rng);
//let best_move: Option<CompleteMove> = None;
match best_move {
None => {
// no available moves; exchange all tiles
@ -309,31 +322,32 @@ impl Game {
}
if self.tile_pool.is_empty(){
TurnAdvanceResult::AIMove {
Ok(TurnAdvanceResult::AIMove {
name: current_player,
action: TurnAction::Pass,
}
})
} else {
self.exchange_tiles(to_exchange).unwrap();
TurnAdvanceResult::AIMove {
self.exchange_tiles(to_exchange)?;
Ok(TurnAdvanceResult::AIMove {
name: current_player,
action: TurnAction::ExchangeTiles(tiles_exchanged),
}
action: TurnAction::ExchangeTiles{tiles_exchanged},
})
}
}
Some(best_move) => {
let play = best_move.convert_to_play(tray);
let score_result = self.receive_play(play, true).unwrap();
TurnAdvanceResult::AIMove {
let score_result = self.receive_play(play, true)?;
Ok(TurnAdvanceResult::AIMove {
name: current_player,
action: TurnAction::PlayTiles(score_result),
}
action: TurnAction::PlayTiles{result: score_result},
})
}
}
} 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)]
#[serde(tag = "type")]
pub enum TurnAction {
Pass,
ExchangeTiles(usize),
PlayTiles(ScoreResult),
ExchangeTiles{
tiles_exchanged: usize
},
PlayTiles{
result: ScoreResult
},
}
#[derive(Deserialize, Tsify, Copy, Clone)]
#[derive(Serialize, Deserialize, Tsify, Debug)]
#[tsify(from_wasm_abi)]
#[serde(tag = "type")]
pub enum TurnAdvanceResult {
HumanInputRequired(String),
HumanInputRequired{
name: String
},
AIMove{
name: String,
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)]
#[tsify(from_wasm_abi)]
pub struct Difficulty {
proportion: f64,
randomness: f64,
pub proportion: f64,
pub randomness: f64,
}
pub struct AI {
@ -92,7 +92,9 @@ impl CompleteMove {
None => {}
}
}
assert!(found_match);
if !found_match {
played_tiles.push(None);
}
}
}
});
@ -174,7 +176,7 @@ impl AI {
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)
(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
};
@ -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
// so that we can possible submit our move
if word.len() >= min_length && self.dictionary.contains(&word) {
all_moves.insert(CompleteMove {
let new_move = CompleteMove {
moves: current_play.clone(),
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
@ -746,6 +749,7 @@ fn find_word_on_line(line_letters: &Vec<Option<Letter>>, line_index: i8) -> Stri
mod tests {
use rand::rngs::SmallRng;
use rand::SeedableRng;
use crate::dictionary::Dictionary;
use super::*;
fn set_cell(board: &mut Board, x: u8, y: u8, letter: char, points: u32) {
@ -984,6 +988,67 @@ mod tests {
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 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,
PlayerAndScore,
ScoreResult,
Tray
Tray, TurnAdvanceResult
} from "word_grid";
import {
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});
if(result.response_type === "ERR") {
@ -181,7 +181,7 @@ export function Game(props: {
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});
if(result.response_type === "ERR") {
@ -223,6 +223,16 @@ export function Game(props: {
<button onClick={() => {
trayDispatch({action: TileDispatchActionType.RETURN});
}}>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 {useState} from "react";
import {GameWasm} from 'word_grid';
import {Difficulty, GameWasm} from 'word_grid';
import {Settings} from "./utils";
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
const logBase: number = 10000;
const processedAIRandomness = Math.log(1 + (logBase - 1)*aiRandomness/100) / Math.log(logBase);
const processedProportionDictionary = 1.0 - proportionDictionary / 100;
if (game == null) {
@ -64,7 +65,11 @@ export function Menu(props: {settings: Settings, dictionary_text: string}) {
<div className="selection-buttons">
<button onClick={() => {
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)}/>
setGame(game);
}}>New Game</button>