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:
parent
9b22f1301d
commit
8fd250170b
6 changed files with 194 additions and 48 deletions
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
124
src/game.rs
124
src/game.rs
|
@ -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(¤t_player).unwrap();
|
let state = self.player_states.get_player_state_mut(¤t_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:?}");
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
|
@ -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());
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
14
src/wasm.rs
14
src/wasm.rs
|
@ -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)?)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
|
@ -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>
|
||||||
</>;
|
</>;
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -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>
|
||||||
|
|
Loading…
Reference in a new issue