From 4fc982eac25c0c8fd3255531f4dc2adb43c53a24 Mon Sep 17 00:00:00 2001 From: Josh Date: Sat, 23 Aug 2025 13:10:05 -0400 Subject: [PATCH] Add some basic algorithms and eval functions for an AI --- binding/python_c_ffi.py | 34 ++++++++- engine/include/ai.h | 4 ++ engine/src/ai/negamax.c | 123 +++++++++++++++++++++++++++++++++ makefile | 58 ++++++++++------ scripts/evaluation.py | 62 +++++++++++++++++ scripts/simulation.py | 149 ++++++++++++++++++++++++++++++++++++++++ 6 files changed, 409 insertions(+), 21 deletions(-) create mode 100644 engine/include/ai.h create mode 100644 engine/src/ai/negamax.c create mode 100644 scripts/evaluation.py create mode 100644 scripts/simulation.py diff --git a/binding/python_c_ffi.py b/binding/python_c_ffi.py index 5725959..32bb0bf 100644 --- a/binding/python_c_ffi.py +++ b/binding/python_c_ffi.py @@ -28,6 +28,9 @@ FILES = {c:i for i,c in enumerate("abcdefgh")} WHITE, BLACK, BOTH = 0, 1, 2 P, N, B, R, Q, K, p, n, b, r, q, k = range(12) +PIECE_CH = ('P','N','B','R','Q','K','p','n','b','r','q','k') +FILES_STR = "a b c d e f g h" + """ Register C function bindings and interfaces in a dictionary. @@ -58,6 +61,9 @@ FFI_SPEC = { "perft": ((C.POINTER(Board), C.c_int), C.c_uint64), "load_fen": ((C.POINTER(Board), C.c_char_p), C.c_int), "gen_pseudo_moves": ((C.POINTER(Board), C.POINTER(Move), C.c_bool), C.c_int), + "apply_move_on_copy": ((C.POINTER(Board), C.POINTER(Board), Move), C.c_bool), + # AI Methods + "ai_find_best_move_with_window": ((C.POINTER(Board), C.c_int, C.c_int, C.POINTER(Move)), C.c_int) } @@ -79,7 +85,29 @@ def draw_bb(mask, origin=None): lines.append(f"{r+1} " + " ".join(row)) lines.append(" " + " ".join(FILES)) lines = "\n".join(lines) - print(lines, "\n") + print(lines, "\n") + + +def print_board(board): + """ + Prints the current position using piece letters from board.pieces. + highlight_sq: optional 0..63 square index to mark with 'O'. + """ + def piece_at(sq: int) -> str: + for i, ch in enumerate(PIECE_CH): + if (int(board.pieces[i]) >> sq) & 1: + return ch + return '.' + + lines = [] + for r in range(7, -1, -1): + row = [] + for f in range(8): + sq = r * 8 + f + row.append(piece_at(sq)) + lines.append(f"{r+1} " + " ".join(row)) + lines.append(" " + FILES_STR) + print("\n" + "\n".join(lines) + "\n") def sq_to_coord(sq): @@ -131,6 +159,10 @@ class ChessFFI: return buf, n + def apply_move_on_copy(self, board, out_board, move): + return bool(self._c_apply_move_on_copy(C.byref(board), C.byref(out_board), move)) + + def get_legal_moves(self, board, out): return self._c_get_legal_moves(board, out) diff --git a/engine/include/ai.h b/engine/include/ai.h new file mode 100644 index 0000000..26ff180 --- /dev/null +++ b/engine/include/ai.h @@ -0,0 +1,4 @@ +#include "bitboard.h" + +int ai_find_best_move_with_window(struct Board *b, int depth, int window_cp, struct Move *best_out); +int ai_play(struct Board *b, int depth); \ No newline at end of file diff --git a/engine/src/ai/negamax.c b/engine/src/ai/negamax.c new file mode 100644 index 0000000..de45e25 --- /dev/null +++ b/engine/src/ai/negamax.c @@ -0,0 +1,123 @@ +#include +#include +#include +#include "ai.h" +#include "bitboard.h" + +#define INF 30000 +#define MATE 29000 + +// P N B R Q K +int VAL[6] = {100, 300, 300, 500, 900, 0}; + +int popcount64(uint64_t bits) { + int count = 0; + while (bits) { + // clear lowest set bit. + bits &= (bits - 1); + ++count; + } + return count; +} + +static int eval(struct Board *board) { + // White minus Black material (centipawns) + // Ignore the King's material. + int score = 0; + score += VAL[0] * (popcount64(board->pieces[P]) - popcount64(board->pieces[p])); + score += VAL[1] * (popcount64(board->pieces[N]) - popcount64(board->pieces[n])); + score += VAL[2] * (popcount64(board->pieces[B]) - popcount64(board->pieces[b])); + score += VAL[3] * (popcount64(board->pieces[R]) - popcount64(board->pieces[r])); + score += VAL[4] * (popcount64(board->pieces[Q]) - popcount64(board->pieces[q])); + + // Negamax convention: score from side-to-move POV + return (board->side_to_move == WHITE) ? score : -score; +} + +static int search(struct Board *b, int depth, int alpha, int beta, int ply) { + if (depth == 0) return eval(b); + + struct Move moves[256]; + int n = get_legal_moves((struct Board*)b, moves); + + if (n == 0) { + // terminal: checkmate or stalemate + // prefer quicker mates + if (in_check(b, b->side_to_move)) { + return -MATE + ply; + } + // Stalemate + return 0; + } + + int best = -INF; + for (int i = 0; i < n; ++i) { + struct Board after; + if (!apply_move_on_copy((struct Board*)b, &after, moves[i])) continue; + + int score = -search(&after, depth - 1, -beta, -alpha, ply + 1); + if (score > best) best = score; + if (score > alpha) alpha = score; + if (alpha >= beta) break; // cutoff + } + return best; +} + +int rand_uniform(int upper_bound) { + if (upper_bound <= 0) return 0; + // Rejection sampling to reduce modulo bias + int limit = RAND_MAX - (RAND_MAX % upper_bound); + int r; + do { r = rand(); } while (r > limit); + return r % upper_bound; +} + +int ai_find_best_move_with_window(struct Board *board, int depth, int window_cp, struct Move *best_out) { + enum { MAX_MOVES = 256 }; + struct Move moves[MAX_MOVES]; + int n = get_legal_moves(board, moves); + if (n <= 0) return 0; + + int scores[MAX_MOVES]; + int best = -INF; + + for (int i = 0; i < n; ++i) { + struct Board after; + if (!apply_move_on_copy(board, &after, moves[i])) return 0; + + // Root child: depth-1; alpha=-INF, beta=+INF; ply starts at 1 + int s = -search(&after, depth - 1, -INF, INF, 1); + scores[i] = s; + if (s > best) best = s; + } + + // collect candidates within window + int cand_idx[MAX_MOVES], cand_n = 0; + for (int i = 0; i < n; ++i) + if (scores[i] >= best - window_cp) + cand_idx[cand_n++] = i; + + // fallback if window excludes all + if (cand_n == 0) { + int best_i = 0; + for (int i = 1; i < n; ++i) + if (scores[i] > scores[best_i]) best_i = i; + *best_out = moves[best_i]; + return 1; + } + + // Select a random candidate to prevent deterministic results. + // I found that games were coming out with the same result each + // time when the AI played itself. + int pick = cand_idx[rand_uniform(cand_n)]; + *best_out = moves[pick]; + return 1; +} + +int ai_play(struct Board *b, int depth) { + struct Move m; + struct Board after; + if (!apply_move_on_copy(b, &after, m)) return 0; + *b = after; + return 1; +} \ No newline at end of file diff --git a/makefile b/makefile index 3e21820..4f4da67 100644 --- a/makefile +++ b/makefile @@ -2,46 +2,64 @@ CC := gcc CFLAGS := -O3 -fPIC -Wall -Wextra LDFLAGS := -shared -SRCDIR := engine/src -INCDIR := engine/include +# ---- paths ---- +SRCDIRS := engine/src engine/src/ai +INCDIRS := engine/include BUILDDIR := build LIBNAME := libchess +LIB := $(BUILDDIR)/$(LIBNAME).so -# Exclude test.c from the shared lib build -SRC := $(filter-out $(SRCDIR)/test.c,$(wildcard $(SRCDIR)/*.c)) -OBJ := $(patsubst $(SRCDIR)/%.c,$(BUILDDIR)/%.o,$(SRC)) -LIB := $(BUILDDIR)/$(LIBNAME).so +# ---- sources/objects ---- +EXCLUDE := engine/src/test.c +SRCS := $(foreach d,$(SRCDIRS),$(wildcard $(d)/*.c)) +SRCS := $(filter-out $(EXCLUDE),$(SRCS)) +OBJS := $(patsubst %.c,$(BUILDDIR)/%.o,$(SRCS)) + +# ---- includes ---- +INCLUDES := $(addprefix -I,$(INCDIRS)) # ---- test executable (engine/src/test.c) ---- -TESTSRC := $(SRCDIR)/test.c -TESTOBJ := $(BUILDDIR)/test.o -TESTBIN := $(BUILDDIR)/print_board +TESTSRC := engine/src/test.c +TESTOBJ := $(BUILDDIR)/engine/src/test.o +TESTBIN := $(BUILDDIR)/print_board -.PHONY: all clean test test-exe run-c-test +# ---- runner (python) ---- +PYTHON := python3 +RUN_SCRIPT := scripts/simulation.py +ARGS ?= + +.PHONY: all clean test test-exe run-c-test run-engine all: $(LIB) -$(BUILDDIR): - @mkdir -p $(BUILDDIR) +# generic object rule: build//file.o from /file.c +$(BUILDDIR)/%.o: %.c + @mkdir -p $(dir $@) + $(CC) $(CFLAGS) $(INCLUDES) -c $< -o $@ -$(BUILDDIR)/%.o: $(SRCDIR)/%.c | $(BUILDDIR) - $(CC) $(CFLAGS) -I$(INCDIR) -c $< -o $@ - -$(LIB): $(OBJ) | $(BUILDDIR) +$(LIB): $(OBJS) + @mkdir -p $(dir $@) $(CC) $(LDFLAGS) -o $@ $^ # ---- test exe rules ---- test-exe: $(TESTBIN) -$(TESTOBJ): $(TESTSRC) | $(BUILDDIR) - $(CC) $(CFLAGS) -I$(INCDIR) -c $< -o $@ +$(TESTOBJ): $(TESTSRC) + @mkdir -p $(dir $@) + $(CC) $(CFLAGS) $(INCLUDES) -c $< -o $@ -$(TESTBIN): $(TESTOBJ) $(LIB) | $(BUILDDIR) +$(TESTBIN): $(TESTOBJ) $(LIB) $(CC) -O2 -o $@ $(TESTOBJ) -L$(BUILDDIR) -lchess -Wl,-rpath,'$$ORIGIN' run-c-test: $(TESTBIN) LD_LIBRARY_PATH=$(BUILDDIR) $(TESTBIN) $(FEN) +# ---- run engine (Python) ---- +# Usage: make run-engine ARGS="--fen '...' --depth 5" +run-engine: $(LIB) + @echo "Running engine via $(RUN_SCRIPT)" + LD_LIBRARY_PATH=$(BUILDDIR) $(PYTHON) $(RUN_SCRIPT) $(ARGS) + clean: @echo "Cleaning build and Python caches..." @rm -rf $(BUILDDIR) @@ -55,4 +73,4 @@ test: @python3 -m unittest -v; \ status=$$?; \ $(MAKE) clean; \ - exit $$status \ No newline at end of file + exit $$status diff --git a/scripts/evaluation.py b/scripts/evaluation.py new file mode 100644 index 0000000..3541111 --- /dev/null +++ b/scripts/evaluation.py @@ -0,0 +1,62 @@ +import random +import ctypes as C +from binding.python_c_ffi import ChessFFI +from binding.python_c_ffi import Move + + +class BaseEvaluation: + def __init__(self, chess_ffi=None): + if chess_ffi is None: + chess_ffi = ChessFFI() + self.chess_ffi = chess_ffi + + + def get_best_move(self, board, legal_moves): + pass + + +""" + We will use a random move evaluation as our base AI. This + is expected to be the worst performing strategy. We can + play our other AI evaluation methods against this one to + confirm if our other strategies are at least better than + a simplistic approach. +""" +class RandomEval(BaseEvaluation): + def __init__(self, chess_ffi=None): + super().__init__(chess_ffi) + + + def get_best_move(self, board, legal_moves): + return random.choice(legal_moves) + + +class NegaMaxEval(BaseEvaluation): + def __init__(self, depth=6, cp_window=20, chess_ffi=None): + super().__init__(chess_ffi) + self.depth = depth + self.window = cp_window + + + def _same(self, a, b): + return (int(getattr(a, "from")) == int(getattr(b, "from")) + and int(a.to) == int(b.to) + and int(getattr(a, "promo", 0) or 0) == int(getattr(b, "promo", 0) or 0)) + + def get_best_move(self, board, legal_moves): + if not legal_moves: + raise RuntimeError("No legal moves") + + best = Move() + ok = self.chess_ffi._c_ai_find_best_move_with_window( + board, + self.depth, + self.window, + best + ) + + if ok: + for m in legal_moves: + if self._same(m, best): + return m + return legal_moves[0] \ No newline at end of file diff --git a/scripts/simulation.py b/scripts/simulation.py new file mode 100644 index 0000000..f103d0d --- /dev/null +++ b/scripts/simulation.py @@ -0,0 +1,149 @@ +from binding.python_c_ffi import Board +from binding.python_c_ffi import Move +from binding.python_c_ffi import ChessFFI +from binding.python_c_ffi import WHITE +from binding.python_c_ffi import print_board +from binding.python_c_ffi import sq_to_coord +from scripts.evaluation import RandomEval +from scripts.evaluation import NegaMaxEval + + +YMD_HM = "%Y-%m-%d-%H-%M" +MAX_MOVES = 256 +DATA_PATH = "./data" + + +class Engine: + def __init__(self, strat_white=None, strat_black=None, max_plys=500): + self.chess_ffi = ChessFFI() + self.board = Board() + self.max_plys = max_plys + self.moves = [] + self._load_attack_cache() + self._seed_engine() + + random_strat = RandomEval(chess_ffi=self.chess_ffi) + nega_strat = NegaMaxEval(chess_ffi=self.chess_ffi, depth=2, cp_window=30) + if not strat_white: + self.strat_white = random_strat + else: + self.strat_white = strat_white + + if not strat_black: + self.strat_black = random_strat + else: + self.strat_black = strat_black + + + def run(self, fen): + self.chess_ffi.load_fen(self.board, fen) + self._clear_moves() + plys = 0 + + while plys <= self.max_plys: + print_board(self.board) + + moves_buf = (Move * MAX_MOVES)() + n_legal = self.chess_ffi.get_legal_moves(self.board, moves_buf) + + game_over, ending = self._is_game_over(n_legal) + if game_over: + print(ending) + break + + legal = [moves_buf[i] for i in range(n_legal)] + if self.board.side_to_move == WHITE: + best_move = self.strat_white.get_best_move(self.board, legal) + else: + best_move = self.strat_black.get_best_move(self.board, legal) + + + new_board = Board() + if not self.chess_ffi.apply_move_on_copy(self.board, new_board, best_move): + print("ERROR: apply_move_on_copy failed") + break + + self.board = new_board + plys += 1 + + move = self.to_uci(best_move) + self.moves.append(move) + + if self.board.halfmove_clock >= 100: + print("draw (50-move rule)") + break + + for move in self.moves: + # print(move) + pass + + + print("done") + print(f"winner: {self.side_tag(not self.board.side_to_move)}") + print(len(self.moves)) + + + def side_tag(self, side): + return 'w' if side == WHITE else 'b' + + + def load_fen(self, fen): + self.chess_ffi.load_fen(self.board, fen) + + + def _clear_moves(self): + self.moves = [] + + + def to_uci(self, move): + fr = sq_to_coord(getattr(move, "from")) + to = sq_to_coord(move.to) + promo = getattr(move, "promo", 0) or "" + + if promo: + MAP = { + 1:"n", + 2:"b", + 3:"r", + 4:"q", + "n":"n", + "b":"b", + "r":"r", + "q":"q" + } + promo = MAP.get(promo, "").lower() + return f"{fr}{to}{promo}" + + + def _load_attack_cache(self): + self.chess_ffi.init_attack_cache() + + + def _seed_engine(self): + import ctypes as C, time + + seed = time.time_ns() + s32 = int(seed) & 0xFFFFFFFF + + libc = C.CDLL("libc.so.6") # on Ubuntu + libc.srand.argtypes = (C.c_uint,) + libc.srand.restype = None + libc.srand(C.c_uint(s32)) + return s32 + + + def _is_game_over(self, legal_moves): + if legal_moves == 0: + if self.chess_ffi.in_check(self.board, self.board.side_to_move): + ending = "checkmate" + else: + ending = "stalemate" + return True, ending + return False, None + + +if __name__ == "__main__": + engine = Engine() + + fen = "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1" + engine.run(fen) \ No newline at end of file