diff --git a/.gitignore b/.gitignore index 0bb75ea..0b5752b 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,4 @@ /bin/ /.shards/ *.dwarf +*.log diff --git a/src/lx_chess.cr b/src/lx_chess.cr index 63f4386..f43dd7c 100644 --- a/src/lx_chess.cr +++ b/src/lx_chess.cr @@ -1,5 +1,7 @@ require "./lx_chess/*" + require "option_parser" +require "log" options = {} of String => String | Nil @@ -28,20 +30,28 @@ OptionParser.parse do |parser| options["pgn_path"] = path end - parser.on("--player-white=PLAYER", "set the type of player") do |player_type| + parser.on("--player-white=PLAYER", "set the type of player (human|computer)") do |player_type| case player_type when /c(omputer)?/i options["player_white"] = "computer" end end - parser.on("--player-black=PLAYER", "set the type of player") do |player_type| + parser.on("--player-black=PLAYER", "set the type of player (human|computer)") do |player_type| case player_type when /c(omputer)?/i options["player_black"] = "computer" end end + parser.on("--log=FILE", "log to file") do |file_Path| + options["log_file"] = file_Path + end + + parser.on("--log-level=LEVEL", "set log level, 0-7") do |level| + options["log_level"] = level + end + parser.invalid_option do |flag| STDERR.puts "ERROR: #{flag} is not a valid option." STDERR.puts parser @@ -49,6 +59,12 @@ OptionParser.parse do |parser| end end +if log_file = options["log_file"]? + log_level = options["log_level"]? || "2" + log_level = Log::Severity.from_value(log_level.to_i8) + Log.setup(log_level, Log::IOBackend.new(File.new(log_file, "a+"))) +end + players = [] of LxChess::Player if player_type = options["player_white"]? diff --git a/src/lx_chess/board.cr b/src/lx_chess/board.cr index 81f4193..6ca0a6b 100644 --- a/src/lx_chess/board.cr +++ b/src/lx_chess/board.cr @@ -8,6 +8,12 @@ module LxChess LETTERS = ('a'..'z').to_a + # Convert an *index* into a human coordinate (ex: `a1`) + def self.cord(index : Int, width = 8) + y, x = index.divmod(width) + "#{LETTERS[x]}#{y + 1}" + end + property :width, :height, :squares def initialize(@width : Int16 = 8, @height : Int16 = 8) diff --git a/src/lx_chess/computer.cr b/src/lx_chess/computer.cr index f4cc13a..70cad74 100644 --- a/src/lx_chess/computer.cr +++ b/src/lx_chess/computer.cr @@ -2,6 +2,7 @@ require "./board" require "./game" require "./player" require "./piece" +require "./move_tree" module LxChess class Computer < Player @@ -10,19 +11,44 @@ module LxChess end def get_move(game : Game, turn : Int8? = nil) - moves = best_moves(game, turn) - moves.any? ? moves.sample : nil + tree = best_moves(game, turn) + + Log.debug do + "Best moves: #{tree.best_branches.map { |b| [game.board.cord(b[0]), game.board.cord(b[1])].join(" => ") + " : #{b[2].score}" }.join(", ")}" + end + move = tree.best_branches.sample + from, to, _ = move + + raise "from is nil" unless from_piece = game.board[from] + + promotion = + case game.turn + when 0 + if from_piece.pawn? + game.board.rank(to) == game.board.height - 1 ? 'Q' : nil + end + when 1 + if from_piece.pawn? + game.board.rank(to) == 0 ? 'Q' : nil + end + end + + Log.debug { "Chose random best move: #{game.board.cord(from)} => #{game.board.cord(to)}" } + {from, to, promotion} end # Evaluate the score of a given *board* # TODO: more positional analysis - def board_score(game : Game) + def board_score(game : Game, turn : Int8? = nil) + turn = turn || game.turn + score = 0 + if game.checkmate? score += game.turn == 0 ? 10_000 : -10_000 end - score + game.board.reduce(0) do |score, piece| + score += game.board.reduce(0) do |score, piece| next score unless piece val = case piece.id @@ -36,11 +62,18 @@ module LxChess end piece.black? ? score - val : score + val end + + return score if game.players.none? + + # If the player is black, a negative value is good + if game.players[game.turn] == self + score.abs + else + -score.abs + end end - # Generate an array of moves (from => to) which result in an even or best score - # TODO: promotion, depth, pruning - def best_moves(game : Game, turn : Int8? = nil) : Array(Array(Int16)) + def move_sets(game : Game, turn : Int8? = nil) turn = turn || game.turn move_sets = game.pieces_for(turn).map do |piece| @@ -48,29 +81,57 @@ module LxChess game.remove_illegal_moves(set) end end.compact + end - _best_moves = [] of Array(Int16) - best_score = 0 + # Generate an array of moves (from => to) which result in an even or best score + # TODO: promotion, pruning + def best_moves(game : Game, turn : Int8? = nil, depth = 0, root_tree : MoveTree? = nil) # : Array(Array(Int16)) + turn = turn || game.turn - move_sets.each do |set| + Log.debug { "Evaluating best moves for turn: #{turn}, depth: #{depth}..." } + + if root_tree.nil? + root_tree = MoveTree.new score: board_score(game, turn), turn: turn + end + + Log.debug { "Current Board score: #{root_tree.score}, turn: #{turn}" } + + move_sets(game, turn).each do |set| origin = set.piece.index set.moves.each do |move| game.tmp_move(from: origin, to: move) do - current_score = board_score(game) + current_score = board_score(game, turn) - if current_score > best_score - best_score = current_score - _best_moves = [] of Array(Int16) - _best_moves << [origin, move] - elsif current_score == best_score - _best_moves << [origin, move] + tree = MoveTree.new(score: current_score, turn: game.next_turn(turn)) + root_tree << {origin, move, tree} + end + end + end + + Log.debug { "Considered #{root_tree.branches.size} moves at depth #{depth}" } + + if depth < 2 + root_tree.branches.each do |branch| + from, to, tree = branch + Log.debug { "Considering #{game.board.cord(from)} => #{game.board.cord(to)} ..." } + + game.tmp_move(from: from, to: to) do + best_moves(game, turn, depth + 1, tree) + + if tree.score > root_tree.score + root_tree.score = tree.score + root_tree.best_branches.truncate(0, 0) + end + + if tree.score == root_tree.score + root_tree.best_branches << branch end end end end - _best_moves + root_tree end end end diff --git a/src/lx_chess/move_tree.cr b/src/lx_chess/move_tree.cr new file mode 100644 index 0000000..16a65cc --- /dev/null +++ b/src/lx_chess/move_tree.cr @@ -0,0 +1,37 @@ +require "./board" + +module LxChess + class MoveTree + alias Branch = Tuple(Int16, Int16, MoveTree) + + property branches = [] of Branch + property score : Int32 + property parent : MoveTree? = nil + + getter best_branches = [] of Branch + getter turn : Int8 + + def initialize(@score, @turn) + end + + def <<(other : Branch) + from, to, tree = other + tree.parent = self + + # a position is only as good as its outcomes + @score = tree.score if @branches.empty? + + if tree.score > @score + Log.debug { "Found better move: #{Board.cord(from)} => #{Board.cord(to)} : #{tree.score}, truncating best branches" } + @score = tree.score + @best_branches.truncate(0, 0) + end + + if tree.score == @score + @best_branches << other + end + + @branches << other + end + end +end diff --git a/src/lx_chess/term_game.cr b/src/lx_chess/term_game.cr index beeaf0e..2a1c68a 100644 --- a/src/lx_chess/term_game.cr +++ b/src/lx_chess/term_game.cr @@ -47,6 +47,7 @@ module LxChess break if @game.checkmate? update end + draw end def update @@ -54,14 +55,7 @@ module LxChess if player.ai? if move = player.get_move(@game) - from, to = move - promotion = - case @game.turn - when 0 - @game.board.rank(to) == @game.board.height - 1 ? 'Q' : nil - when 1 - @game.board.rank(to) == 0 ? 'Q' : nil - end + from, to, promotion = move san = @game.move_to_san(from, to, promotion) @changes << @game.make_move(from, to, promotion) @@ -86,10 +80,11 @@ module LxChess when /suggest/i @gb.clear if suggestions = @evaluator.best_moves(@game) - suggestions.each do |suggestion| - @gb.highlight([suggestion[0]]) - @gb.highlight([suggestion[1]], "red") - @log.unshift " #{suggestion.map { |s| @game.board.cord(s) }.join(" => ")}" + suggestions.best_branches.each do |branch| + from, to, _ = branch + @gb.highlight([from]) + @gb.highlight([to], "red") + @log.unshift " #{@game.board.cord(from)} => #{@game.board.cord(to)}" end @log.unshift "suggestions:" end @@ -146,7 +141,7 @@ module LxChess end when nil else - if input + unless input.blank? notation = Notation.new(input) from, to = @game.parse_san(notation) if from && to