fixed AI, added win state

This commit is contained in:
andy 2021-07-13 12:07:33 +01:00
parent d922c0f345
commit eb1a240bd7
13 changed files with 226 additions and 126 deletions

View File

@ -1,6 +1,6 @@
[package]
name = "draught"
version = "0.1.0"
version = "1.0.0"
authors = ["aj <andrewjpack@gmail.com>"]
edition = "2018"
repository = "https://github.com/Sarsoo/draught"
@ -10,6 +10,8 @@ crate-type = ["cdylib", "rlib"]
[features]
default = ["console_error_panic_hook"]
debug_logs = [] # log extra stuff to the web console
time_ex = [] # allow time profiling in computer
[dependencies]
wasm-bindgen = "0.2.74"

View File

@ -5,10 +5,12 @@ Draught
## [Try it Out!](https://sarsoo.github.io/draught/)
WASM-based checkers game. Looking to implement a minimax based AI.
WebAssembly-based checkers game with a minimax-based AI player.
Rust WASM module for game logic with a JS frontend for rendering and processing user input.
![Screenshot](./docs/screenshot.png)
## Building
1. Setup a Rust + wasm-pack environment and a Node environment

BIN
docs/screenshot.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

View File

@ -3,6 +3,7 @@ use wasm_bindgen::prelude::*;
use std::fmt::{Display};
/// Move/Jump, for use in Move
#[wasm_bindgen]
#[repr(u8)]
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
@ -11,6 +12,7 @@ pub enum MoveType {
Jump = 1,
}
/// Black/White
#[wasm_bindgen]
#[repr(u8)]
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
@ -20,6 +22,7 @@ pub enum Team {
}
impl Team {
/// Get opposing team
pub fn opponent(&self) -> Team{
match self {
Team::White => Team::Black,
@ -37,6 +40,7 @@ impl Display for Team {
}
}
/// Man/King
#[wasm_bindgen]
#[repr(u8)]
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
@ -45,6 +49,7 @@ pub enum Strength {
King = 1
}
/// Model board square as Empty/Occupied/Unplayable
#[wasm_bindgen]
#[repr(u8)]
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
@ -64,6 +69,7 @@ impl Display for SquareState {
}
}
/// Possible outcomes of trying to move
#[wasm_bindgen]
#[repr(u8)]
#[derive(Clone, Copy, Debug, PartialEq, Eq)]

View File

@ -491,6 +491,7 @@ impl Board {
}
}
/// Check that given move trajectory is valid for a man piece
pub fn validate_man_move(&self, from: BrdIdx, to: BrdIdx, from_square_occupant: Piece) -> Moveable {
let (row_diff, col_diff) = Board::idx_diffs(from, to);
@ -539,6 +540,7 @@ impl Board {
}
}
/// Check that given move trajectory is valid for a king piece
pub fn validate_king_move(&self, from: BrdIdx, to: BrdIdx, from_square_occupant: Piece) -> Moveable {
let (row_diff, col_diff) = Board::idx_diffs(from, to);

View File

@ -20,6 +20,7 @@ use Team::*;
#[cfg(test)] pub mod tests;
/// Represents a move by source/destination indices and the move type
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Move {
from: BrdIdx,
@ -35,6 +36,7 @@ impl Move {
}
}
/// For storing boards in the AI tree, stores board with score for comparisons
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct BoardNode {
pub board: Board,
@ -148,7 +150,7 @@ impl Computer {
}
/// Propagate scores up the tree employing MiniMax algorithm
fn propagate_scores(&mut self, tree: Arena<BoardNode>, root: NodeId) -> Arena<BoardNode> {
fn propagate_scores(tree: Arena<BoardNode>, root: NodeId) -> Arena<BoardNode> {
// need to clone tree because we iterate over it and edit it at the same time
let mut new_tree = tree.clone();
@ -158,17 +160,17 @@ impl Computer {
if let NodeEdge::End(node_id) = n {
// board current being looked at
let board_node = tree
let board_node = new_tree
.get(node_id) // get Node
.expect("No node returned for node id")
.get(); // get BoardNode from Node
// get scores of each nodes children
let children_scores: Vec<isize> = node_id // current node
.children(&tree)
.children(&new_tree)
.into_iter()
.map(
|n| tree
|n| new_tree
.get(n) // get Node
.expect("No node returned for node id") // unwrap, should always be fine
.get() // get BoardNode from Node
@ -192,6 +194,7 @@ impl Computer {
new_tree
}
/// Get best of given scores for given team
fn best_score(board: &Board, children_scores: Vec<isize>) -> isize {
match board.current_turn { // MiniMax algorithm here
// whether maximised or minimsed is based on current player
@ -305,6 +308,7 @@ impl Computer {
).collect()
}
/// Get a new board based on the given using MiniMax to make decisions
pub fn get_move(&mut self, brd: Board) -> Option<Board> {
let mut tree = Arena::new();
@ -321,7 +325,7 @@ impl Computer {
self.insert_board_scores(&mut tree, lowest_nodes);
// propagate the scores up the tree, the root node has the best score
let tree = self.propagate_scores(tree, root_node);
let tree = Computer::propagate_scores(tree, root_node);
// get root node to compare
let root_board_node = tree
@ -329,22 +333,22 @@ impl Computer {
.expect("No node returned for node id")
.get(); // get BoardNode from Node
log!("{}", root_board_node.score);
// log!("{:#?}", tree);
// when boards have equal scores, store for shuffling and selection
let mut equal_scores = Vec::with_capacity(10);
// DEBUG
let scores: Vec<NodeId> = root_node
.children(&tree)
.collect();
let scores: Vec<isize> = scores
.into_iter()
.map(|n| tree.get(n).unwrap().get().score)
.collect();
log!("SCORES: {:?}", scores);
// 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)
.collect();
log!("Next boards scores: {:?}", scores);
}
// search through root node's children for the same score
for n in root_node.children(&tree) {

View File

@ -146,9 +146,21 @@ fn best_scores() {
}
#[wasm_bindgen_test]
fn propagate_scores() {
let brd = Board::init_game(Board::new(8, 8, White), 3);
let mut comp = Computer::new(3, White);
fn insert_scores_all_take() {
// . _ . _ .
// W . W . W
// . B . B .
// _ . _ . _
// 4 available moves, all are white taking black
let mut brd = Board::new(5, 4, White);
brd.set_cell(brd.cell_idx(BrdIdx::from(1, 2)), Square::pc(White, Man));
brd.set_cell(brd.cell_idx(BrdIdx::from(1, 0)), Square::pc(White, Man));
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);
// log!("{}", brd);
@ -157,100 +169,95 @@ fn propagate_scores() {
comp.expand_layer(&mut tree, vec!(root));
let moves = comp.propagate_scores(tree, root);
// log!("{}", moves.len());
let lowest_nodes = comp.get_leaf_nodes(&mut tree, root);
// insert the board scores for the leaf nodes
comp.insert_board_scores(&mut tree, lowest_nodes);
// log!("{}", tree.count());
let children_scores: Vec<isize> = root // current node
.children(&tree)
.into_iter()
.map(
|n| tree
.get(n) // get Node
.expect("No node returned for node id") // unwrap, should always be fine
.get() // get BoardNode from Node
.score // get score from BoardNode
)
.collect(); // finalise
assert_eq!(children_scores, vec!(-3, -3, -3, -3));
}
// #[wasm_bindgen_test]
// fn tree_2_depth() {
// // log!("{}", performance.timing().request_start());
#[wasm_bindgen_test]
fn insert_scores_one_take() {
// . _ . _ .
// W . _ . W
// . B . _ .
// _ . _ . _
// let iter = 3;
// let mut times = Vec::with_capacity(iter);
// 4 available moves, all are white taking black
// for _ in 0..iter {
// times.push(time_tree_gen(6));
// }
let mut brd = Board::new(5, 4, White);
// brd.set_cell(brd.cell_idx(BrdIdx::from(1, 2)), Square::pc(White, Man));
brd.set_cell(brd.cell_idx(BrdIdx::from(1, 0)), Square::pc(White, Man));
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);
// log!("{:?}", times);
// }
// log!("{}", brd);
// fn time_tree_gen(depth: usize) {
// web_sys::console::time_with_label("tree_timer");
let mut tree = Arena::new();
let root = tree.new_node(BoardNode::brd(brd));
// let mut comp = Computer::new(depth, White);
comp.expand_layer(&mut tree, vec!(root));
// let mut tree = Arena::new();
// let brd = Board::init_game(Board::new(8, 8, White), 3);
let lowest_nodes = comp.get_leaf_nodes(&mut tree, root);
// insert the board scores for the leaf nodes
comp.insert_board_scores(&mut tree, lowest_nodes);
// comp.gen_tree(&mut tree, brd);
let children_scores: Vec<isize> = root // current node
.children(&tree)
.into_iter()
.map(
|n| tree
.get(n) // get Node
.expect("No node returned for node id") // unwrap, should always be fine
.get() // get BoardNode from Node
.score // get score from BoardNode
)
.collect(); // finalise
// web_sys::console::time_end_with_label("tree_timer");
// log!("{}", tree.count());
// }
// log!("{:?}", children_scores);
// #[wasm_bindgen_test]
// fn tree_last_nodes() {
// let mut brd = Board::new(2, 2, White);
// brd.set_cell(1, Square::pc(White, King));
// let mut comp = Computer::new(3, White);
assert_eq!(children_scores, vec!(-3, -1));
}
// // log!("{}", brd);
// // log!("{}", brd.score());
#[cfg(feature = "time_ex")]
#[wasm_bindgen_test]
fn tree_2_depth() {
// // let moves = comp.available_turns(&brd);
// // log!("{}", moves.len());
let iter = 3;
let mut times = Vec::with_capacity(iter);
// let mut tree = Arena::new();
// let root_node = comp.gen_tree(&mut tree, brd);
for _ in 0..iter {
times.push(time_tree_gen(6));
}
}
// let lowest_nodes = comp.get_leaf_nodes(&mut tree, root_node);
#[cfg(feature = "time_ex")]
fn time_tree_gen(depth: usize) {
web_sys::console::time_with_label("tree_timer");
// // log!("{:#?}", lowest_nodes);
let mut comp = Computer::new(depth, White);
// comp.insert_board_scores(&mut tree, lowest_nodes);
let mut tree = Arena::new();
let brd = Board::init_game(Board::new(8, 8, White), 3);
// // log!("{:#?}", tree);
// // log!("{}", tree.count());
// }
comp.gen_tree(&mut tree, brd);
// #[wasm_bindgen_test]
// fn tree_score_propagation() {
// let mut brd = Board::new(4, 4, White);
// brd.set_cell(brd.cell_idx(BrdIdx::from(1, 2)), Square::pc(White, Man));
// brd.set_cell(brd.cell_idx(BrdIdx::from(1, 0)), Square::pc(White, Man));
// brd.set_cell(brd.cell_idx(BrdIdx::from(2, 1)), Square::pc(Black, Man));
// let mut comp = Computer::new(1, White);
// // log!("{}", brd);
// // log!("{}", brd.score());
// // let moves = comp.available_turns(&brd);
// // log!("{}", moves.len());
// let mut tree = Arena::new();
// let root_node = comp.gen_tree(&mut tree, brd);
// // log!("{}", root_node);
// let lowest_nodes = comp.get_leaf_nodes(&mut tree, root_node);
// // log!("{:#?}", lowest_nodes);
// comp.insert_board_scores(&mut tree, lowest_nodes);
// let tree = comp.propagate_scores(tree, root_node);
// let scores: Vec<NodeId> = root_node
// .children(&tree)
// .collect();
// let scores: Vec<isize> = scores.into_iter().map(|n| tree.get(n).unwrap().get().score).collect();
// // log!("SCORES: {:?}", scores);
// // log!("{:#?}", tree);
// // log!("{}", tree.count());
// }
web_sys::console::time_end_with_label("tree_timer");
}
// #[wasm_bindgen_test]
// fn tree_get_move() {

View File

@ -12,7 +12,7 @@ use crate::board::enums::{SquareState, Moveable, Team};
use crate::paint::Painter;
use crate::comp::Computer;
// use Team::*;
use Team::*;
use SquareState::*;
use std::fmt::{Display};
@ -65,6 +65,31 @@ impl Game {
self.current.score()
}
/// Get currently winning player
pub fn winning(&self) -> Option<Team> {
let current_score = self.score();
if current_score < 0 {
Some(White)
} else if current_score == 0 {
None
} else {
Some(Black)
}
}
/// Check if a player has won
pub fn has_won(&self) -> Option<Team> {
if self.current.num_player(White) == 0 {
Some(Black)
} else if self.current.num_player(Black) == 0 {
Some(White)
} else {
None
}
}
/// Get square on current board for given index
pub fn current_cell_state(&self, idx: &BrdIdx) -> Square {
self.current.cell(self.current.cell_idx(*idx))
@ -143,6 +168,7 @@ impl Game {
self.current = board;
}
/// Get new game without board renderer
#[wasm_bindgen(constructor)]
pub fn new(width: usize, height: usize, piece_rows: usize, first_turn: Team, search_depth: usize) -> Game {
Game {
@ -157,6 +183,7 @@ impl Game {
}
}
/// Get a new game with canvas ID and dimensions
pub fn new_with_canvas(width: usize, height: usize, piece_rows: usize, first_turn: Team, search_depth: usize, canvas_id: &str, canvas_width: u32, canvas_height: u32) -> Game {
Game {
current: Board::init_game(
@ -172,10 +199,12 @@ impl Game {
}
}
/// Set painter for rendering boards
pub fn set_painter(&mut self, value: Painter) {
self.painter = Some(value);
}
/// Draw current board using painter if exists
pub fn draw(&self) {
match &self.painter {
Some(p) => p.draw(&self.current),
@ -183,6 +212,7 @@ 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);

View File

@ -5,7 +5,6 @@
pub mod board;
pub mod utils;
pub mod game;
pub mod player;
pub mod paint;
pub mod comp;

View File

@ -1,8 +0,0 @@
extern crate wasm_bindgen;
use wasm_bindgen::prelude::*;
#[wasm_bindgen]
#[derive(Clone)]
pub struct Player {
score: usize,
}

View File

@ -26,6 +26,12 @@
font-family: monospace;
}
.no-select {
-webkit-user-select: none; /* Safari */
-ms-user-select: none; /* IE 10 and IE 11 */
user-select: none; /* Standard syntax */
}
#game-canvas {
width: 1000px;
height: 1000px;
@ -73,21 +79,21 @@
<div class="card-header">
<h1>Draught 🚀</h1>
</div>
<div class="card-body">
<div class="card-body no-select">
<div class="row p-1">
<div class="col-sm-12">
<p class="text-muted">Working on an implementation of checkers in Rust WASM with a thin Js frontend, mainly as an exercise to learn Rust and to have a larger project in the language to fiddle with. The idea is to use the <a href="https://en.wikipedia.org/wiki/Minimax">minimax</a> algorithm to create an AI player that can operate with reasonable performance as a result of Rust's compiled performance.</p>
<p class="text-muted">An implementation of checkers in Rust WASM with a thin Js frontend, mainly as an exercise to learn Rust and to have a larger project in the language to fiddle with. Using the <a href="https://en.wikipedia.org/wiki/Minimax">minimax</a> algorithm for an AI player that can operate with reasonable performance as a result of Rust's compiled performance.</p>
</div>
</div>
<div class="row p-3">
<div class="col-sm-12">
<a href="doc/draught" class="btn btn-secondary" target="_blank">Docs</a>
<button id="startBtn" class="btn btn-success">Start</button>
<button id="startBtn" class="btn btn-success" title="reset the game and start again">Start</button>
</div>
</div>
<div class="row p-3">
<div class="col-sm-4">
<div class="col-sm-4" title="board width in cells">
<input type="number"
id="width"
name="width"
@ -95,7 +101,7 @@
class="form-control">
<label for="width">width</label>
</div>
<div class="col-sm-4">
<div class="col-sm-4" title="board height in cells">
<input type="number"
id="height"
name="height"
@ -103,7 +109,7 @@
class="form-control">
<label for="height">height</label>
</div>
<div class="col-sm-4">
<div class="col-sm-4" title="number of rows to populate with pieces per player">
<input type="number"
id="play_rows"
name="play_rows"
@ -113,28 +119,35 @@
</div>
</div>
<div class="row p-3">
<div class="col-sm-4">
<input class="form-check-input" type="checkbox" value="" id="ai-checkbox" checked="checked">
<div class="col-sm-4" title="should the AI play?">
<input class="form-check-input"
type="checkbox"
value=""
id="ai-checkbox"
checked="checked">
<label class="form-check-label" for="ai-checkbox">
AI Player
</label>
</div>
<div class="col-sm-4">
<div class="col-sm-4" 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</label>
<label for="ai_search_depth">ai difficulty <small class="text-muted">moves ahead</small></label>
</div>
<div class="col-sm-4">
<div class="col-sm-4" title="how many nodes were expanded in the search tree">
<p class="text-muted" id="node-count"></p>
</div>
</div>
<div class="row p-3">
<div class="col-sm-12">
<div class="col-sm-6" title="current turn">
<h1 id="team-p"></h1>
</div>
<div class="col-sm-6" title="who's winning">
<h1 id="winning-p"></h1>
</div>
</div>
<div class="row p-3">
<div class="col-sm-12">

View File

@ -15,6 +15,7 @@ var PIECE_ROWS = 3;
var SEARCH_DEPTH = 4;
const STATUS_TIMEOUT = 3000;
const WON_TIMEOUT = 3000;
const GameState = {
HUMAN_TURN: {
@ -36,10 +37,12 @@ const statusText = document.getElementById("status-p");
const statusAlert = document.getElementById("status-d");
const teamText = document.getElementById("team-p");
const nodeCountText = document.getElementById("node-count");
const winningText = document.getElementById("winning-p");
const startBtn = document.getElementById("startBtn");
startBtn.onclick = start_game;
let wonTimeout = null;
let statusTimeout = null;
let setStatus = setStatusAlert;
@ -78,7 +81,9 @@ function start_game() {
game.set_painter(painter);
game.draw();
clearInterval(wonTimeout);
updateTeamText();
updateWinningText();
clicks = [];
current_state = GameState.HUMAN_TURN.THINKING;
}
@ -131,12 +136,10 @@ function process_canvas_click(cell_coord) {
switch(status) {
case Moveable.Allowed:
console.log(`Score after your turn: ${game.score()}`);
if (aiCheckBox.checked) {
game.ai_move();
nodeCountText.innerText = `searched ${game.last_node_count.toLocaleString("en-GB")} possible moves`;
console.log(`Score after the AI's turn: ${game.score()}`);
}
break;
@ -180,6 +183,8 @@ function process_canvas_click(cell_coord) {
}
updateTeamText();
updateWinningText();
checkWon();
}
function getMousePos(canvas, evt) {
@ -232,6 +237,41 @@ function updateTeamText(){
}
}
function updateWinningText(){
switch(game.winning()) {
case undefined:
winningText.innerText = "";
break;
case Team.White:
winningText.innerText = "👑 White 👑";
break;
case Team.Black:
winningText.innerText = "👑 Black 👑";
break;
}
}
function checkWon() {
switch(game.has_won()) {
case undefined:
break;
case Team.White:
setStatus("You Lost!");
wonTimeout = setInterval(() => {
start_game();
}, WON_TIMEOUT);
break;
case Team.Black:
setStatus("You Won!", "success");
wonTimeout = setInterval(() => {
start_game();
}, WON_TIMEOUT);
break;
}
}
////////////////
// UI
////////////////
@ -268,7 +308,6 @@ const pieceRowsBox = document.getElementById("play_rows");
const onPieceRows = () => {
PIECE_ROWS = parseInt(pieceRowsBox.value);
console.log(typeof(PIECE_ROWS));
start_game();
}
pieceRowsBox.onchange = onPieceRows;
@ -282,6 +321,10 @@ const onAISearchDepth = () => {
SEARCH_DEPTH = parseInt(aiSearchDepthBox.value);
game.set_search_depth(SEARCH_DEPTH);
if(SEARCH_DEPTH > 4) {
setStatus("This increases thinking time exponentially, be careful (probably don't go past 6)", "warning");
}
}
aiSearchDepthBox.onchange = onAISearchDepth;
aiSearchDepthBox.value = 4;
@ -291,7 +334,7 @@ const aiCheckBox = document.getElementById("ai-checkbox");
* Handler for height input box change, get a new universe of given size
*/
const onAICheck = () => {
console.log(aiCheckBox.checked);
}
aiCheckBox.onchange = onAICheck;
// aiCheckBox.checked = true;

View File

@ -1,6 +1,6 @@
{
"name": "draught",
"version": "0.1.0",
"version": "1.0.0",
"description": "Rust wasm-based checkers game",
"main": "index.js",
"scripts": {