AI perfect chance, rendering with proportions instead of absolute pixel values

This commit is contained in:
andy 2021-07-14 14:08:54 +01:00
parent ab0d0e5df3
commit 40d47f8042
8 changed files with 154 additions and 63 deletions

View File

@ -1,12 +1,15 @@
//! AI player logic
use indextree::{Arena, Node, NodeId, NodeEdge};
use rand::prelude::*;
use rand::seq::SliceRandom;
extern crate wasm_bindgen;
// use wasm_bindgen::prelude::*;
use crate::log;
use crate::log_error;
use crate::board::{Board, BrdIdx};
use crate::board::enums::{MoveType, Moveable, Team};
@ -63,13 +66,15 @@ pub struct Computer {
pub search_depth: usize,
pub team: Team,
pub last_node_count: usize,
pub perfect_chance: f64,
}
impl Computer {
pub fn new(search_depth: usize, team: Team) -> Computer {
pub fn new(search_depth: usize, team: Team, perfect_chance: f64) -> Computer {
Computer {
search_depth,
team,
perfect_chance,
last_node_count: 0,
}
}
@ -333,49 +338,88 @@ impl Computer {
.expect("No node returned for node id")
.get(); // get BoardNode from Node
// when boards have equal scores, store for shuffling and selection
let mut equal_scores = Vec::with_capacity(10);
// node ids of available next moves
let possible_moves: Vec<NodeId> = root_node.children(&tree).collect();
if possible_moves.len() == 0 {
return None;
}
// DEBUG
#[cfg(feature = "debug_logs")]
{
log!("Current root score: {}", root_board_node.score);
let scores: Vec<NodeId> = root_node
.children(&tree)
.collect();
let scores: Vec<isize> = scores
.into_iter()
.map(|n| tree.get(n).unwrap().get().score)
let scores: Vec<isize> = possible_moves
.iter()
.map(|n| tree.get(*n).unwrap().get().score)
.collect();
log!("Next boards scores: {:?}", scores);
}
// search through root node's children for the same score
for n in root_node.children(&tree) {
let mut rng = rand::thread_rng();
// random number to compare against threshold
let perfect_num: f64 = rng.gen();
// get each board
let iter_board_node = tree
.get(n) // get Node
.expect("No node returned for node id")
.get(); // get BoardNode from Node
// make perfect move
if perfect_num < self.perfect_chance {
#[cfg(feature = "debug_logs")]
log!("Making perfect move");
if root_board_node.score == iter_board_node.score {
equal_scores.push(iter_board_node);
// return Some(iter_board_node.board.clone());
// get boards of equal score that are perfect for the given player
let possible_perfect_moves: Vec<&BoardNode> = possible_moves
.iter()
.map(
// get immutable references to BoardNodes for possible moves
|n| tree
.get(*n) // get Node using NodeID
.expect("Unable to get perfect move data from tree node")
.get() // get *BoardNode from Node
)
.filter(
// filter for only scores of root node which are perfect moves
|b| b.score == root_board_node.score
)
.collect();
// weird error, no child nodes have same score as root node
// this is odd because the root nodes score is either the max or min of it's children
if possible_perfect_moves.len() == 0 {
log_error!("No next moves matched the score of the root node, picking randomly instead");
Some(Computer::random_choice(&tree, possible_moves, &mut rng))
}
// only one possible move, use that
else if possible_perfect_moves.len() == 1 {
Some(possible_perfect_moves[0].board.clone())
}
if equal_scores.len() == 0 {
None
} else if equal_scores.len() == 1 {
Some(equal_scores[0].board.clone())
} else {
Some(equal_scores
.choose(&mut rand::thread_rng())
.unwrap()
// more than one possible perfect move to make, choose one randomly
else {
Some(
possible_perfect_moves
.choose(&mut rng) // random choice
.unwrap() // unwrap Option
.board
.clone()
)
}
}
// get random move
else {
#[cfg(feature = "debug_logs")]
log!("Making random move");
Some(Computer::random_choice(&tree, possible_moves, &mut rng))
}
}
/// Get a random board from possible node IDs and associated tree
fn random_choice(tree: &Arena<BoardNode>, possible_moves: Vec<NodeId>, rng: &mut ThreadRng) -> Board {
let chosen_move = possible_moves.choose(rng).unwrap();
tree
.get(*chosen_move)
.expect("Unable to get random move data from tree node")
.get()
.board
.clone()
}
}

View File

@ -4,7 +4,7 @@ use wasm_bindgen_test::*;
use crate::board::Square;
use crate::board::enums::Strength::*;
use crate::log;
// use crate::log;
// use Team::*;
@ -25,7 +25,7 @@ fn available_moves() {
// _ . _
let mut brd = Board::new(3, 2, White);
let comp = Computer::new(3, White);
let comp = Computer::new(3, White, 0.5);
// log!("{}", brd);
@ -50,7 +50,7 @@ fn available_moves_jumps() {
// _ . _ .
let mut brd = Board::new(4, 4, White);
let comp = Computer::new(3, White);
let comp = Computer::new(3, White, 0.5);
// log!("{}", brd);
@ -71,7 +71,7 @@ fn available_moves_jumps() {
#[wasm_bindgen_test]
fn available_moves_std_brd() {
let brd = Board::init_game(Board::new(8, 8, White), 3);
let comp = Computer::new(3, White);
let comp = Computer::new(3, White, 0.5);
// log!("{}", brd);
@ -87,7 +87,7 @@ fn available_moves_std_brd() {
#[wasm_bindgen_test]
fn expand_node() {
let brd = Board::init_game(Board::new(8, 8, White), 3);
let mut comp = Computer::new(3, White);
let mut comp = Computer::new(3, White, 0.5);
// log!("{}", brd);
@ -106,7 +106,7 @@ fn expand_node() {
#[wasm_bindgen_test]
fn expand_layer() {
let brd = Board::init_game(Board::new(8, 8, White), 3);
let mut comp = Computer::new(3, White);
let mut comp = Computer::new(3, White, 0.5);
// log!("{}", brd);
@ -123,7 +123,7 @@ fn expand_layer() {
#[wasm_bindgen_test]
fn leaf_nodes() {
let brd = Board::init_game(Board::new(8, 8, White), 3);
let mut comp = Computer::new(3, White);
let mut comp = Computer::new(3, White, 0.5);
let mut tree = Arena::new();
let id = tree.new_node(BoardNode::brd(brd));
@ -160,7 +160,7 @@ fn insert_scores_all_take() {
brd.set_cell(brd.cell_idx(BrdIdx::from(1, 4)), Square::pc(White, Man));
brd.set_cell(brd.cell_idx(BrdIdx::from(2, 1)), Square::pc(Black, Man));
brd.set_cell(brd.cell_idx(BrdIdx::from(2, 3)), Square::pc(Black, Man));
let mut comp = Computer::new(1, White);
let mut comp = Computer::new(1, White, 0.5);
// log!("{}", brd);
@ -203,7 +203,7 @@ fn insert_scores_one_take() {
brd.set_cell(brd.cell_idx(BrdIdx::from(1, 4)), Square::pc(White, Man));
brd.set_cell(brd.cell_idx(BrdIdx::from(2, 1)), Square::pc(Black, Man));
// brd.set_cell(brd.cell_idx(BrdIdx::from(2, 3)), Square::pc(Black, Man));
let mut comp = Computer::new(1, White);
let mut comp = Computer::new(1, White, 0.5);
// log!("{}", brd);

View File

@ -29,6 +29,7 @@ pub struct Game {
painter: Option<Painter>,
search_depth: usize,
pub last_node_count: usize,
pub perfect_chance: f64,
}
impl Game {
@ -95,6 +96,7 @@ impl Game {
self.current.cell(self.current.cell_idx(*idx))
}
/// Set tree depth for AI to search to
pub fn set_search_depth(&mut self, search_depth: usize) {
self.search_depth = search_depth;
}
@ -114,6 +116,11 @@ impl Game {
}
}
/// Set proportion of perfect moves from AI
pub fn set_perfect_chance(&mut self, new_chance: f64) {
self.perfect_chance = new_chance;
}
/// Clear currently selected piece
pub fn clear_selected(&mut self) {
self.selected_piece = None;
@ -180,6 +187,7 @@ impl Game {
painter: None,
search_depth,
last_node_count: 0,
perfect_chance: 0.5,
}
}
@ -196,6 +204,7 @@ impl Game {
),
search_depth,
last_node_count: 0,
perfect_chance: 0.5,
}
}
@ -215,14 +224,22 @@ impl Game {
/// Create computer, get move from current board and update current board
pub fn ai_move(&mut self) {
let mut comp = Computer::new(self.search_depth, self.current.current_turn);
let mut comp = Computer::new(self.search_depth, self.current.current_turn, self.perfect_chance);
let new_brd = comp.get_move(self.current.clone());
self.last_node_count = comp.last_node_count;
match new_brd {
Some(brd) => self.push_new_board(brd),
None => panic!("No AI move returned"),
None => {
log!("No possible moves, re-pushing current board");
let mut new_brd = self.current.clone();
new_brd.current_turn = new_brd.current_turn.opponent();
self.push_new_board(new_brd);
},
}
}
}

View File

@ -6,7 +6,7 @@ wasm_bindgen_test_configure!(run_in_browser);
// use crate::board::{Piece};
use crate::board::enums::Strength::*;
use crate::board::enums::Team::*;
// use crate::board::enums::Team::*;
#[wasm_bindgen_test]

View File

@ -29,6 +29,12 @@ macro_rules! log {
web_sys::console::log_1(&format!( $( $t )* ).into());
}
}
#[macro_export]
macro_rules! log_error {
( $( $t:tt )* ) => {
web_sys::console::error_1(&format!( $( $t )* ).into());
}
}
#[wasm_bindgen]
pub fn init_wasm() {

View File

@ -23,8 +23,8 @@ const BLACK_SQUARE: &str = "#000000";
/// Default hex colour value for outline of black squares
const SQUARE_OUTLINE: &str = "#9c9c9c";
/// Line width when outlining black squares
const OUTLINE_WIDTH: f64 = 3.0;
/// Line width when outlining black squares as proportion of min cell dimension
const OUTLINE_WIDTH: f64 = 0.05;
/// Whether to outline black squares
const DRAW_OUTLINE: bool = true;
@ -46,10 +46,10 @@ const SELECTED_PIECE_OUTLINE: &str = "#d1cf45";
const KING_OUTLINE: &str = "#ffea00";
/// Whether to outline pieces
const DRAW_PIECE_OUTLINES: bool = true;
/// Line width for outlining pieces
const PIECE_OUTLINE_WIDTH: f64 = 6.0;
/// Margin from square to define piece radius
const PIECE_MARGIN: f64 = 14.0;
/// Line width for outlining pieces as proportion of piece radius
const PIECE_OUTLINE_PROPORTION: f64 = 0.25;
/// Proportion of square that piece fills as proportion of min cell dimension
const PIECE_PROPORTION: f64 = 0.6;
/// Used to paint boards onto HTML canvases
#[wasm_bindgen]
@ -72,7 +72,7 @@ pub struct Painter {
king_line: JsValue,
piece_lines: bool,
piece_line_width: f64,
piece_line_proportion: f64,
square_outline: JsValue,
outline_width: f64,
@ -168,7 +168,7 @@ impl Painter {
selected_piece_line: JsValue::from_str(SELECTED_PIECE_OUTLINE),
king_line: JsValue::from_str(KING_OUTLINE),
piece_lines: DRAW_PIECE_OUTLINES,
piece_line_width: PIECE_OUTLINE_WIDTH,
piece_line_proportion: PIECE_OUTLINE_PROPORTION,
square_outline: JsValue::from_str(SQUARE_OUTLINE),
outline_width: OUTLINE_WIDTH,
@ -201,7 +201,7 @@ impl Painter {
selected_piece_line: JsValue::from_str(SELECTED_PIECE_OUTLINE),
king_line: JsValue::from_str(KING_OUTLINE),
piece_lines: DRAW_PIECE_OUTLINES,
piece_line_width: PIECE_OUTLINE_WIDTH,
piece_line_proportion: PIECE_OUTLINE_PROPORTION,
square_outline: JsValue::from_str(SQUARE_OUTLINE),
outline_width: OUTLINE_WIDTH,
@ -255,12 +255,19 @@ impl Painter {
let cell_height = self.height as usize / board.height;
let cell_width = self.width as usize / board.width;
let min_dimension = usize::min(cell_width, cell_height) as f64;
let cell_radius = min_dimension * PIECE_PROPORTION / 2.0;
let piece_outline = cell_radius * self.piece_line_proportion;
let square_outline = min_dimension * self.outline_width;
self.context.set_fill_style(&self.white_square);
self.context.fill_rect(0.0, 0.0, self.width as f64, self.height as f64);
self.context.set_fill_style(&self.black_square);
self.context.set_stroke_style(&self.square_outline);
self.context.set_line_width(self.outline_width);
self.context.set_line_width(square_outline);
// Draw black squares onto canvas
for i in 0..board.height {
@ -336,7 +343,7 @@ impl Painter {
match self.context.arc(
center_x,
center_y,
(cell_width as f64 / 2.0) - PIECE_MARGIN, // radius
cell_radius, // radius
0.0, // start angle
f64::consts::PI * 2.0) // end angle
{
@ -346,7 +353,7 @@ impl Painter {
self.context.fill();
if self.piece_lines {
self.context.set_line_width(self.piece_line_width);
self.context.set_line_width(piece_outline);
self.context.stroke()
}
@ -366,7 +373,7 @@ impl Painter {
match self.context.arc(
center_x,
center_y,
(cell_width as f64 / 2.0) - PIECE_MARGIN, // radius
cell_radius, // radius
0.0, // start angle
f64::consts::PI * 2.0) // end angle
{
@ -376,7 +383,7 @@ impl Painter {
self.context.fill();
if self.piece_lines {
self.context.set_line_width(self.piece_line_width);
self.context.set_line_width(piece_outline);
self.context.stroke()
}
}

View File

@ -119,7 +119,7 @@
</div>
</div>
<div class="row p-3">
<div class="col-sm-4" title="should the AI play?">
<div class="col-sm-3" title="should the AI play?">
<input class="form-check-input"
type="checkbox"
value=""
@ -129,15 +129,19 @@
AI Player
</label>
</div>
<div class="col-sm-4" title="how many layers deep should the AI search (grows exponentially, be careful)">
<div class="col-sm-3" title="how many layers deep should the AI search (grows exponentially, be careful)">
<input type="number"
id="ai_search_depth"
name="ai_search_depth"
min="1" max="10" value="4"
class="form-control">
<label for="ai_search_depth">ai difficulty <small class="text-muted">moves ahead</small></label>
<label for="ai_search_depth">ai clairvoyance <small class="text-muted">moves ahead</small></label>
</div>
<div class="col-sm-4" title="how many nodes were expanded in the search tree">
<div class="col-sm-3" title="what percentage of the AI's moves should be perfect?">
<label for="ai_difficulty" class="form-label">ai difficulty <small class="text-muted">%</small></label>
<input type="range" class="form-range" min="1" max="100" id="ai_difficulty">
</div>
<div class="col-sm-3" title="how many nodes were expanded in the search tree">
<p class="text-muted" id="node-count"></p>
</div>
</div>

View File

@ -13,6 +13,7 @@ var BOARD_HEIGHT = 8;
var PIECE_ROWS = 3;
var SEARCH_DEPTH = 4;
var PERFECT_CHANCE = 0.5;
const STATUS_TIMEOUT = 3000;
const WON_TIMEOUT = 3000;
@ -137,7 +138,7 @@ function process_canvas_click(cell_coord) {
switch(status) {
case Moveable.Allowed:
if (aiCheckBox.checked) {
if (aiCheckBox.checked && game.has_won() === undefined) {
game.ai_move();
nodeCountText.innerText = `searched ${game.last_node_count.toLocaleString("en-GB")} possible moves`;
}
@ -338,3 +339,15 @@ const onAICheck = () => {
}
aiCheckBox.onchange = onAICheck;
// aiCheckBox.checked = true;
const aiPerfectChance = document.getElementById("ai_difficulty");
/**
* Handler for piece rows input box change, start a new game
*/
const onPerfectChance = () => {
PERFECT_CHANCE = parseInt(aiPerfectChance.value) / 100;
game.set_perfect_chance(PERFECT_CHANCE);
}
aiPerfectChance.onchange = onPerfectChance;
aiPerfectChance.value = 50;