Compare commits

...

5 Commits

Author SHA1 Message Date
Karma Riuk
947114877b moved everything related to python in the python
Some checks failed
pre-release / Pre Release (push) Waiting to run
tagged-release / Tagged Release (push) Has been cancelled
folder
2025-02-02 13:20:59 +01:00
Karma Riuk
166e1c7664 added some positions to test the move generation 2025-02-02 13:18:25 +01:00
Karma Riuk
84d73511d2 fixed a bunch of issues with the move generation 2025-02-02 12:58:05 +01:00
Karma Riuk
4bb068b2a5 added complete FEN support both for reading and writing 2025-02-01 18:34:41 +01:00
Karma Riuk
92e1ff26fc added pawn promotion 2025-02-01 16:43:02 +01:00
32 changed files with 450 additions and 34 deletions

View File

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

Before

Width:  |  Height:  |  Size: 3.5 KiB

After

Width:  |  Height:  |  Size: 3.5 KiB

View File

Before

Width:  |  Height:  |  Size: 1.8 KiB

After

Width:  |  Height:  |  Size: 1.8 KiB

View File

Before

Width:  |  Height:  |  Size: 734 B

After

Width:  |  Height:  |  Size: 734 B

View File

Before

Width:  |  Height:  |  Size: 2.7 KiB

After

Width:  |  Height:  |  Size: 2.7 KiB

View File

Before

Width:  |  Height:  |  Size: 1014 B

After

Width:  |  Height:  |  Size: 1014 B

View File

Before

Width:  |  Height:  |  Size: 4.2 KiB

After

Width:  |  Height:  |  Size: 4.2 KiB

View File

Before

Width:  |  Height:  |  Size: 2.5 KiB

After

Width:  |  Height:  |  Size: 2.5 KiB

View File

Before

Width:  |  Height:  |  Size: 3.0 KiB

After

Width:  |  Height:  |  Size: 3.0 KiB

View File

Before

Width:  |  Height:  |  Size: 2.2 KiB

After

Width:  |  Height:  |  Size: 2.2 KiB

View File

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

Before

Width:  |  Height:  |  Size: 3.8 KiB

After

Width:  |  Height:  |  Size: 3.8 KiB

View File

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

169
python/src/ai/ai.py Normal file
View File

@ -0,0 +1,169 @@
from collections import defaultdict
import time
from tqdm import tqdm
from logic.board import INITIAL_BOARD, Board
from logic.move import Move
from logic.pieces.piece import Colour
pos2expected = {
# -- Position 1
"rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1": {
1: 20,
2: 400,
3: 8_902,
4: 197_281,
},
# -- Position 2
"r3k2r/p1ppqpb1/bn2pnp1/3PN3/1p2P3/2N2Q1p/PPPBBPPP/R3K2R w KQkq - 0 1": {
1: 48,
2: 2_039,
3: 97_862,
4: 4_085_603,
},
# -- Position 3
"8/2p5/3p4/KP5r/1R3p1k/8/4P1P1/8 w - - 0 1": {
1: 14,
2: 191,
3: 2_812,
4: 43_238,
5: 674_624,
},
# -- Position 4
"r3k2r/Pppp1ppp/1b3nbN/nP6/BBP1P3/q4N2/Pp1P2PP/R2Q1RK1 w kq - 0 1": {
1: 6,
2: 264,
3: 9467,
4: 422_333,
},
# -- Position 5
"rnbq1k1r/pp1Pbppp/2p5/8/2B5/8/PPP1NnPP/RNBQK2R w KQ - 1 8": {
1: 44,
2: 1486,
3: 62379,
4: 2103487,
},
# -- Position 6
"r4rk1/1pp1qppp/p1np1n2/2b1p1B1/2B1P1b1/P1NP1N2/1PP1QPPP/R4RK1 w - - 0 10": {
1: 46,
2: 2079,
3: 89890,
4: 3894594,
},
}
tick = "\033[92m✔\033[0m" # Green Tick
cross = "\033[91m❌\033[0m" # Red Cross
res = defaultdict(lambda : 0)
def peft(pos: str):
global res
expected = pos2expected[pos]
board = Board.setup_FEN_position(pos)
for depth in expected:
with tqdm(total=expected[depth], desc=f"Depth: {depth}") as bar:
start = time.process_time()
moves = move_generation_test(bar, board, depth, depth)
bar.close()
elapsed = time.process_time() - start
elapsed *= 1_000
print("Depth:", depth, end=" ")
print("Result:", moves, end=" ")
if moves == expected[depth]:
print(f"{tick}", end=" ")
else:
print(f"{cross} (expected {expected[depth]})", end=" ")
print("positions Time:", int(elapsed), "milliseconds")
if moves != expected[depth]:
print()
for key, value in res.items():
print(f"{key}: {value}")
def move_generation_test(bar, board: Board, depth: int, max_depth, first = None):
global res
if first is None:
res = defaultdict(lambda : 0)
if board.is_terminal():
# bar.update(1)
# res[first] += 1
return 0
if depth == 0:
res[first] += 1
bar.update(1)
return 1
moves = board.legal_moves()
if depth == 1:
res[first] += len(moves)
bar.update(len(moves))
return len(moves)
num_pos = 0
for move in moves:
tmp_board = board.make_move(move)
if first is None:
first = move.piece.pos.to_algebraic() + move.pos.to_algebraic()
if first == "f7h8":
print(tmp_board.legal_moves())
num_pos += move_generation_test(bar, tmp_board, depth - 1, max_depth, first = first)
if depth == max_depth:
first = None
return num_pos
def play_game(board: Board, strategies: dict, verbose: bool = False) -> Board:
"""Play a turn-taking game. `strategies` is a {player_name: function} dict,
where function(state) is used to get the player's move."""
state = board
move_counter = 1
while not (board.is_checkmate_for(board._turn) or board.is_stalemate_for(board._turn)):
player = board._turn
move = strategies[player](state)
state = board.make_move(move)
if verbose:
if player == Colour.WHITE:
print(str(move_counter) + ".", move, end=" ")
else:
print(move)
return state
def minmax_search(state: Board) -> tuple[float, Move]:
"""Search game tree to determine best move; return (value, move) pair."""
return _max_value(state) if state._turn == Colour.WHITE else _min_value(state)
def _max_value(state: Board) -> tuple[float, Move]:
if state.is_terminal():
return state.utility(), None
v, move = -float("inf"), None
for a in state.legal_moves():
v2, _ = _min_value(state.make_move(a))
if v2 > v:
v, move = v2, a
return v, move
def _min_value(state: Board) -> tuple[float, Move]:
if state.is_terminal():
return state.utility(), None
v, move = float("inf"), None
for a in state.legal_moves():
v2, _ = _min_value(state.make_move(a))
if v2 < v:
v, move = v2, a
return v, move

View File

@ -18,6 +18,8 @@ class Board:
self._white_castling_rights = set()
self._black_castling_rights = set()
self._en_passant_target = None
self._n_moves = 0
self._n_half_moves = 0
@staticmethod
def _piece_class_from_char(c: str) -> Type[Piece]:
@ -83,6 +85,8 @@ class Board:
for c in position[index:]:
index += 1
if c == "-" or c == " ":
if c == "-":
index += 1
break
sides = "kq"
@ -98,7 +102,24 @@ class Board:
# -- En passant target
if position[index] != "-":
ret._en_passant_target = position[index:index+2]
pos = Position.from_algebraic(position[index:index+2])
index += 2
if pos.y == 2:
pos.y += 1
assert pos in ret._white, "En passant target is not in the position"
ret._en_passant_target = ret._white[pos]
elif pos.y == 5:
pos.y -= 1
assert pos in ret._black, "En passant target is not in the position"
ret._en_passant_target = ret._black[pos]
else:
raise ValueError("You can't have a en passant target that is not on the third or sixth rank")
else:
index += 1
index += 1
ret._n_half_moves = int(position[index:position.find(" ", index + 1)])
ret._n_moves = int(position[position.find(" ", index)+1:])
return ret
@ -180,10 +201,22 @@ class Board:
if move.pos in other_pieces:
del other_pieces[move.pos]
if piece.colour == Colour.BLACK:
ret._n_moves = self._n_moves + 1
if move.is_capturing or type(piece) == Pawn:
ret._n_half_moves = 0
else:
ret._n_half_moves = self._n_half_moves + 1
if move.en_passant:
pos_to_remove = Position(move.pos.x, move.pos.y + (1 if self._turn == Colour.BLACK else -1))
del other_pieces[pos_to_remove]
if move.promotes_to is not None:
assert type(piece) == Pawn, "Trying to promote something that is not a pawn: not good!"
pieces_moving[move.pos] = move.promotes_to(move.pos, piece.colour)
# -- Set en passant target if needed
if move.becomes_en_passant_target:
ret._en_passant_target = pieces_moving[move.pos]
@ -215,6 +248,12 @@ class Board:
ret._white_castling_rights.remove(CastleSide.Queen)
elif piece.pos.x == 7 and CastleSide.King in ret._white_castling_rights:
ret._white_castling_rights.remove(CastleSide.King)
if move.is_capturing and move.pos.y == 7 and move.pos in self._black and type(self._black[move.pos]) == Rook:
if move.pos.x == 0 and CastleSide.Queen in ret._black_castling_rights:
ret._black_castling_rights.remove(CastleSide.Queen)
elif move.pos.x == 7 and CastleSide.King in ret._black_castling_rights:
ret._black_castling_rights.remove(CastleSide.King)
else:
if type(piece) == King:
ret._black_castling_rights = set()
@ -225,7 +264,97 @@ class Board:
elif piece.pos.x == 7 and CastleSide.King in ret._black_castling_rights:
ret._black_castling_rights.remove(CastleSide.King)
if move.is_capturing and move.pos.y == 0 and move.pos in self._white and type(self._white[move.pos]) == Rook:
if move.pos.x == 0 and CastleSide.Queen in ret._white_castling_rights:
ret._white_castling_rights.remove(CastleSide.Queen)
elif move.pos.x == 7 and CastleSide.King in ret._white_castling_rights:
ret._white_castling_rights.remove(CastleSide.King)
return ret
INITIAL_BOARD = Board.setup_FEN_position("rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1")
def to_fen_string(self):
ret = ""
for y in range(7, -1, -1):
empty_cell_counter = 0
for x in range(8):
pos = Position(x, y)
piece = None
if pos in self._white:
piece = self._white[pos]
elif pos in self._black:
piece = self._black[pos]
if piece is None:
empty_cell_counter += 1
continue
if empty_cell_counter > 0:
ret += str(empty_cell_counter)
empty_cell_counter = 0
letter = piece.letter()
ret += letter.lower() if piece.colour == Colour.BLACK else letter.upper()
if empty_cell_counter > 0:
ret += str(empty_cell_counter)
if y > 0:
ret += "/"
ret += " "
ret += "w" if self._turn == Colour.WHITE else "b"
ret += " "
if len(self._white_castling_rights) == 0 and len(self._black_castling_rights) == 0:
ret += "-"
else:
if CastleSide.King in self._white_castling_rights:
ret += "K"
if CastleSide.Queen in self._white_castling_rights:
ret += "Q"
if CastleSide.King in self._black_castling_rights:
ret += "k"
if CastleSide.Queen in self._black_castling_rights:
ret += "q"
ret += " "
if self._en_passant_target is not None:
pos = Position(self._en_passant_target.pos.x, self._en_passant_target.pos.y)
pos.y += -1 if self._en_passant_target.colour == Colour.WHITE else 1
ret += pos.to_algebraic()
else:
ret += "-"
ret += " "
ret += str(self._n_half_moves)
ret += " "
ret += str(self._n_moves)
return ret
def legal_moves(self) -> list[Move]:
ret = []
pieces = self._white if self._turn == Colour.WHITE else self._black
for piece in pieces.values():
ret += piece.legal_moves(self)
return ret
def is_terminal(self) -> bool:
return self.is_stalemate_for(Colour.WHITE) or self.is_stalemate_for(Colour.BLACK) or self.is_checkmate_for(Colour.WHITE) or self.is_checkmate_for(Colour.BLACK)
def utility(self) -> int:
if self.is_stalemate_for(Colour.WHITE) or self.is_stalemate_for(Colour.BLACK):
return 0
if self.is_checkmate_for(Colour.WHITE):
return 1
if self.is_checkmate_for(Colour.BLACK):
return -1
raise ValueError("Cannot determine the utility of board become it neither checkmate nor stalemate for either players")
_fen_pos = "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1"
INITIAL_BOARD = Board.setup_FEN_position(_fen_pos)

View File

@ -1,4 +1,5 @@
# from logic.pieces.piece import Piece
from typing import Type
from logic.position import Position
from enum import Enum
@ -8,13 +9,14 @@ class CastleSide(Enum):
Queen = "O-O-O"
class Move:
def __init__(self, piece: "Piece", pos: Position,/, is_capturing: bool = False, castle_side: CastleSide = CastleSide.Neither, en_passant: bool = False, becomes_en_passant_target: bool = False) -> None:
def __init__(self, piece: "Piece", pos: Position,/, is_capturing: bool = False, castle_side: CastleSide = CastleSide.Neither, en_passant: bool = False, becomes_en_passant_target: bool = False, promotes_to: Type["Piece"] = None) -> None:
self.piece = piece
self.pos = pos
self.is_capturing = is_capturing
self.castle_side = castle_side
self.becomes_en_passant_target = becomes_en_passant_target
self.en_passant = en_passant
self.promotes_to = promotes_to
def to_algebraic(self) -> str:
raise NotImplementedError("The move can't be translated to algbraic notation, as it was not implemented")
@ -30,10 +32,19 @@ class Move:
return "O-O-O"
ret = ""
if type(self.piece).__name__ != "Pawn":
if type(self.piece).__name__ == "Pawn":
if self.is_capturing:
ret += self.piece.pos.to_algebraic()[0]
ret += "x"
ret += self.pos.to_algebraic()
else:
ret += self.pos.to_algebraic()
else:
ret += self.piece.letter().upper()
ret += str(self.pos)
if self.is_capturing:
ret += "x"
ret += str(self.pos)
return ret
def __repr__(self) -> str:

View File

@ -17,7 +17,7 @@ class Bishop(Piece):
# looking north west
ret.extend(self._look_direction(board, -1, 1))
if not looking_for_check and board.is_check_for(self.colour):
if not looking_for_check:# and board.is_check_for(self.colour):
return self.keep_only_blocking(ret, board)
return ret

View File

@ -47,7 +47,7 @@ class King(Piece):
if CastleSide.Queen in castling_rights:
clear = True
for dx in range(1, 3):
for dx in range(1, 4):
x = self.pos.x - dx
y = self.pos.y
@ -57,7 +57,7 @@ class King(Piece):
move = self._move_for_position(board, x, y)
board_after_move = board.make_move(move)
if board_after_move.is_check_for(self.colour):
if dx < 3 and board_after_move.is_check_for(self.colour):
clear = False
break

View File

@ -16,7 +16,7 @@ class Knight(Piece):
if move is not None:
ret.append(move)
if not looking_for_check and board.is_check_for(self.colour):
if not looking_for_check:# and board.is_check_for(self.colour):
return self.keep_only_blocking(ret, board)
return ret

View File

@ -1,5 +1,9 @@
from logic.move import Move
from logic.pieces.bishop import Bishop
from logic.pieces.knight import Knight
from logic.pieces.piece import Colour, Piece
from logic.pieces.queen import Queen
from logic.pieces.rook import Rook
from logic.position import Position
class Pawn(Piece):
@ -13,7 +17,11 @@ class Pawn(Piece):
(self.colour == Colour.BLACK and (capturable_piece := board.piece_at(self.pos.x - 1, self.pos.y - 1)))
):
if capturable_piece.colour != self.colour:
ret.append(Move(self, capturable_piece.pos, is_capturing = True))
if (self.colour == Colour.WHITE and capturable_piece.pos.y == 7) or (self.colour == Colour.BLACK and capturable_piece.pos.y == 0):
for piece in [Queen, Knight, Bishop, Rook]:
ret.append(Move(self, capturable_piece.pos, is_capturing=True, promotes_to=piece))
else:
ret.append(Move(self, capturable_piece.pos, is_capturing = True))
# can we capture to the right?
if self.pos.x < 7 and (
@ -22,7 +30,11 @@ class Pawn(Piece):
(self.colour == Colour.BLACK and (capturable_piece := board.piece_at(self.pos.x + 1, self.pos.y - 1)))
):
if capturable_piece.colour != self.colour:
ret.append(Move(self, capturable_piece.pos, is_capturing = True))
if (self.colour == Colour.WHITE and capturable_piece.pos.y == 7) or (self.colour == Colour.BLACK and capturable_piece.pos.y == 0):
for piece in [Queen, Knight, Bishop, Rook]:
ret.append(Move(self, capturable_piece.pos, is_capturing=True, promotes_to=piece))
else:
ret.append(Move(self, capturable_piece.pos, is_capturing = True))
# -- Can we capture en passant?
if board._en_passant_target is not None and \
@ -38,18 +50,31 @@ class Pawn(Piece):
# -- Normal moves
if self.colour == Colour.WHITE:
for dy in range(1, 3 if self.pos.y == 1 else 2):
if self.pos.y + dy > 7 or board.piece_at(self.pos.x, self.pos.y + dy):
y = self.pos.y + dy
if y > 7 or board.piece_at(self.pos.x, y):
break
pos = Position(self.pos.x, self.pos.y + dy)
ret.append(Move(self, pos, becomes_en_passant_target=dy==2))
pos = Position(self.pos.x, y)
if y == 7:
for piece in [Queen, Knight, Bishop, Rook]:
ret.append(Move(self, pos, promotes_to=piece))
else:
ret.append(Move(self, pos, becomes_en_passant_target=dy==2))
else:
for dy in range(1, 3 if self.pos.y == 6 else 2):
if self.pos.y - dy < 0 or board.piece_at(self.pos.x, self.pos.y - dy):
y = self.pos.y - dy
if y < 0 or board.piece_at(self.pos.x, y):
break
pos = Position(self.pos.x, self.pos.y - dy)
ret.append(Move(self, pos, becomes_en_passant_target=dy==2))
pos = Position(self.pos.x, y)
if y == 0:
for piece in [Queen, Knight, Bishop, Rook]:
ret.append(Move(self, pos, promotes_to=piece))
else:
ret.append(Move(self, pos, becomes_en_passant_target=dy==2))
if not looking_for_check and board.is_check_for(self.colour):
if not looking_for_check:# and board.is_check_for(self.colour):
return self.keep_only_blocking(ret, board)
return ret
def letter(self):
return "p"

View File

@ -29,7 +29,7 @@ class Queen(Piece):
# looking north
ret.extend(self._look_direction(board, 0, 1))
if not looking_for_check and board.is_check_for(self.colour):
if not looking_for_check:# and board.is_check_for(self.colour):
return self.keep_only_blocking(ret, board)
return ret

View File

@ -17,7 +17,7 @@ class Rook(Piece):
# looking north
ret.extend(self._look_direction(board, 0, 1))
if not looking_for_check and board.is_check_for(self.colour):
if not looking_for_check:# and board.is_check_for(self.colour):
return self.keep_only_blocking(ret, board)
return ret

View File

@ -17,6 +17,14 @@ class Position:
return x >= Position._MIN_POS and x <= Position._MAX_POS \
and y >= Position._MIN_POS and y <= Position._MAX_POS
@staticmethod
def from_algebraic(square: str) -> "Position":
assert len(square) == 2, f"'{square}' is malformed"
x = Position._FILES.index(square[0])
y = Position._RANKS.index(int(square[1]))
return Position(x, y)
def to_algebraic(self) -> str:
return f"{Position._FILES[self.x]}{Position._RANKS[self.y]}"

27
python/src/main.py Normal file
View File

@ -0,0 +1,27 @@
import time
from pprint import pprint
from tqdm import tqdm
from ai.ai import move_generation_test
from controller.controller import Controller
from logic.board import INITIAL_BOARD, Board
from logic.position import Position
from view.gui import GUI
from view.tui import TUI
from ai.ai import peft
if __name__ == "__main__":
board = INITIAL_BOARD
pos = "rnbq1k1r/pp1Pbppp/2p5/8/2B5/8/PPP1NnPP/RNBQK2R w KQ - 1 8"
board = Board.setup_FEN_position(pos)
view = GUI()
controller = Controller(board, view)
# view.show()
# exit()
peft(pos)

30
python/tests/fen.py Normal file
View File

@ -0,0 +1,30 @@
import unittest
import sys
sys.path.append('src') # you must execute pytest from the stickfosh dir for this to work
from logic.board import Board
class FENTests(unittest.TestCase):
def testInitialPosition(self):
pos = "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1"
self.assertEqual(pos, Board.setup_FEN_position(pos).to_fen_string())
def testRandomPositions(self):
pos = "r1bk3r/p2pBpNp/n4n2/1p1NP2P/6P1/3P4/P1P1K3/q5b1 b Qk - 0 1"
self.assertEqual(pos, Board.setup_FEN_position(pos).to_fen_string())
pos = "rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR b KQkq e3 0 1"
self.assertEqual(pos, Board.setup_FEN_position(pos).to_fen_string())
pos = "4k2r/6r1/8/8/8/8/3R4/R3K3 w Qk - 0 1"
self.assertEqual(pos, Board.setup_FEN_position(pos).to_fen_string())
pos = "8/8/8/4p1K1/2k1P3/8/8/8 b - - 0 1"
self.assertEqual(pos, Board.setup_FEN_position(pos).to_fen_string())
pos = "8/5k2/3p4/1p1Pp2p/pP2Pp1P/P4P1K/8/8 b - - 99 50"
self.assertEqual(pos, Board.setup_FEN_position(pos).to_fen_string())

30
python/tests/position.py Normal file
View File

@ -0,0 +1,30 @@
import unittest
import sys
sys.path.append('src') # you must execute pytest from the stickfosh dir for this to work
from logic.position import Position
class PositionTests(unittest.TestCase):
def testXY2Algebraic(self):
self.assertEqual(Position(0, 0).to_algebraic(), "a1")
self.assertEqual(Position(1, 0).to_algebraic(), "b1")
self.assertEqual(Position(2, 1).to_algebraic(), "c2")
self.assertEqual(Position(4, 2).to_algebraic(), "e3")
self.assertEqual(Position(7, 7).to_algebraic(), "h8")
def testAlgebraic2XY(self):
self.assertEqual(Position.from_algebraic("a1"), Position(0, 0))
self.assertEqual(Position.from_algebraic("b1"), Position(1, 0))
self.assertEqual(Position.from_algebraic("c2"), Position(2, 1))
self.assertEqual(Position.from_algebraic("e3"), Position(4, 2))
self.assertEqual(Position.from_algebraic("h8"), Position(7, 7))
self.assertRaises(AssertionError, lambda : Position.from_algebraic("a11"))
self.assertRaises(ValueError, lambda : Position.from_algebraic("j1"))
self.assertRaises(ValueError, lambda : Position.from_algebraic("a9"))

View File

@ -1,13 +0,0 @@
from controller.controller import Controller
from logic.board import INITIAL_BOARD
from view.gui import GUI
from view.tui import TUI
if __name__ == "__main__":
board = INITIAL_BOARD
view = GUI()
controller = Controller(board, view)
view.show()