From 60a987a7a1636c8e2f17dd6c869bba9764ee129a Mon Sep 17 00:00:00 2001 From: Josh Date: Sat, 23 Aug 2025 13:00:28 -0400 Subject: [PATCH 1/9] Add halfmove and fullmove clock count --- engine/src/bitboard.c | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/engine/src/bitboard.c b/engine/src/bitboard.c index bc7ba41..31ac845 100644 --- a/engine/src/bitboard.c +++ b/engine/src/bitboard.c @@ -715,6 +715,14 @@ bool apply_move_on_copy(struct Board *in, struct Board *out, struct Move m) { in->castling_rights, m.piece, m.from, m.to, captured_pid ); + + // Apply full and half move clock counts. + bool is_capture = (m.flags & MF_CAPTURE) || (m.flags & MF_ENPASSANT); + bool is_pawn = (m.piece == P) || (m.piece == p); + + out->halfmove_clock = (is_capture || is_pawn) ? 0 : in->halfmove_clock + 1; + out->fullmove_number = in->fullmove_number + (in->side_to_move == BLACK); + // Side to move flips out->side_to_move = opp; -- 2.34.1 From 4fc982eac25c0c8fd3255531f4dc2adb43c53a24 Mon Sep 17 00:00:00 2001 From: Josh Date: Sat, 23 Aug 2025 13:10:05 -0400 Subject: [PATCH 2/9] 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 -- 2.34.1 From c82c50e6ddecddbe3afe4741f8d8d9ac820515f1 Mon Sep 17 00:00:00 2001 From: Josh Date: Mon, 25 Aug 2025 13:01:36 -0400 Subject: [PATCH 3/9] Remove unused function and add missing header prototypes --- engine/include/bitboard.h | 5 ++++- engine/src/ai/negamax.c | 8 -------- 2 files changed, 4 insertions(+), 9 deletions(-) diff --git a/engine/include/bitboard.h b/engine/include/bitboard.h index acd9318..1a9aab1 100644 --- a/engine/include/bitboard.h +++ b/engine/include/bitboard.h @@ -114,4 +114,7 @@ void gen_white_pawn_captures(const struct Board *board, struct Move *moves, int void gen_black_pawn_captures(const struct Board *board, struct Move *moves, int *count); void gen_white_pawn_capture_promotions(const struct Board *board, struct Move *moves, int *count); void gen_black_pawn_capture_promotions(const struct Board *board, struct Move *moves, int *count); -int gen_pseudo_moves(const struct Board *board, struct Move *out, bool captures_only); \ No newline at end of file +int gen_pseudo_moves(const struct Board *board, struct Move *out, bool captures_only); +int get_legal_moves(struct Board *board, struct Move *out); +bool in_check(const struct Board *board, enum Color side); +bool apply_move_on_copy(struct Board *in, struct Board *out, struct Move m); \ No newline at end of file diff --git a/engine/src/ai/negamax.c b/engine/src/ai/negamax.c index de45e25..87fbe99 100644 --- a/engine/src/ai/negamax.c +++ b/engine/src/ai/negamax.c @@ -112,12 +112,4 @@ int ai_find_best_move_with_window(struct Board *board, int depth, int window_cp, 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 -- 2.34.1 From 9fa0a959d5a54400c0e9bd7074a28c2a32fdba90 Mon Sep 17 00:00:00 2001 From: Josh Date: Mon, 25 Aug 2025 13:02:20 -0400 Subject: [PATCH 4/9] Add data to gitignore --- .gitignore | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 286dc47..c2f2db2 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ build -__pycache__ \ No newline at end of file +data +__pycache__ -- 2.34.1 From 633cdd2d7decb3ae3e0e4457547befd8186c710e Mon Sep 17 00:00:00 2001 From: Josh Date: Mon, 25 Aug 2025 13:02:53 -0400 Subject: [PATCH 5/9] Remove same check --- scripts/evaluation.py | 22 ++++++++++------------ 1 file changed, 10 insertions(+), 12 deletions(-) diff --git a/scripts/evaluation.py b/scripts/evaluation.py index 3541111..e95251e 100644 --- a/scripts/evaluation.py +++ b/scripts/evaluation.py @@ -15,6 +15,10 @@ class BaseEvaluation: pass + def get_params(self): + pass + + """ We will use a random move evaluation as our base AI. This is expected to be the worst performing strategy. We can @@ -38,15 +42,7 @@ class NegaMaxEval(BaseEvaluation): 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, @@ -56,7 +52,9 @@ class NegaMaxEval(BaseEvaluation): ) if ok: - for m in legal_moves: - if self._same(m, best): - return m - return legal_moves[0] \ No newline at end of file + return best + return legal_moves[0] + + + def get_params(self): + return [self.depth, self.window] \ No newline at end of file -- 2.34.1 From cbe16aba8a342deda9c0442cd726db3e7ebfaa81 Mon Sep 17 00:00:00 2001 From: Josh Date: Mon, 25 Aug 2025 20:10:38 -0400 Subject: [PATCH 6/9] Add board to fen function --- binding/python_c_ffi.py | 10 +++++- engine/include/fen.h | 1 + engine/src/fen.c | 74 +++++++++++++++++++++++++++++++++++++++++ 3 files changed, 84 insertions(+), 1 deletion(-) diff --git a/binding/python_c_ffi.py b/binding/python_c_ffi.py index 32bb0bf..0d46501 100644 --- a/binding/python_c_ffi.py +++ b/binding/python_c_ffi.py @@ -62,6 +62,8 @@ FFI_SPEC = { "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), + "board_to_fen": ((C.POINTER(Board), C.c_char_p, C.c_size_t), None), + # AI Methods "ai_find_best_move_with_window": ((C.POINTER(Board), C.c_int, C.c_int, C.POINTER(Move)), C.c_int) } @@ -151,7 +153,13 @@ class ChessFFI: def load_fen(self, board, fen_string): return self._c_load_fen(board, fen_string.encode("ascii")) - + + + def board_to_fen(self, board, size=256): + buf = C.create_string_buffer(size) + self._c_board_to_fen(board, buf, size) + return buf.value.decode("ascii") + def gen_pseudo_moves(self, board, captures_only=False, cap=256): buf = (Move * cap)() diff --git a/engine/include/fen.h b/engine/include/fen.h index 1decd7c..b2fa21a 100644 --- a/engine/include/fen.h +++ b/engine/include/fen.h @@ -1 +1,2 @@ int load_fen(); +void board_to_fen(struct Board *board, char *out, size_t out_sz); diff --git a/engine/src/fen.c b/engine/src/fen.c index 2485ca9..6c63450 100644 --- a/engine/src/fen.c +++ b/engine/src/fen.c @@ -10,6 +10,11 @@ int square_index(int file, int rank) { return rank*8 + file; } +static const char PIECE_CH[12] = { + 'P','N','B','R','Q','K', + 'p','n','b','r','q','k' +}; + int char_to_piece_index(char c) { switch (c) { case 'P': return P; case 'N': return N; case 'B': return B; @@ -117,4 +122,73 @@ int load_fen(struct Board *board, const char *fen) { board->occ[BOTH] = board->occ[WHITE] | board->occ[BLACK]; return 0; +} + +void board_to_fen(const struct Board *board, char *out, size_t out_sz) { + char fen[256]; + char *p = fen; + + // 1. Piece placement + for (int rank = 7; rank >= 0; rank--) { + int empty = 0; + for (int file = 0; file < 8; file++) { + int sq = rank * 8 + file; + char piece = 0; + + for (int i = 0; i < 12; i++) { + if (board->pieces[i] & (1ULL << sq)) { + piece = PIECE_CH[i]; + break; + } + } + + if (piece) { + if (empty > 0) { + *p++ = '0' + empty; + empty = 0; + } + *p++ = piece; + } else { + empty++; + } + } + if (empty > 0) *p++ = '0' + empty; + if (rank > 0) *p++ = '/'; + } + + // 2. Side to move + *p++ = ' '; + *p++ = (board->side_to_move == WHITE) ? 'w' : 'b'; + + // 3. Castling rights + *p++ = ' '; + bool any = false; + if (board->castling_rights & CASTLE_WK) { *p++ = 'K'; any = true; } + if (board->castling_rights & CASTLE_WQ) { *p++ = 'Q'; any = true; } + if (board->castling_rights & CASTLE_BK) { *p++ = 'k'; any = true; } + if (board->castling_rights & CASTLE_BQ) { *p++ = 'q'; any = true; } + if (!any) *p++ = '-'; + + // 4. En passant + *p++ = ' '; + if (board->ep_square >= 0) { + int file = board->ep_square % 8; + int rank = board->ep_square / 8; + *p++ = 'a' + file; + *p++ = '1' + rank; + } else { + *p++ = '-'; + } + + // 5. Halfmove clock + p += sprintf(p, " %d", board->halfmove_clock); + + // 6. Fullmove number + p += sprintf(p, " %d", board->fullmove_number); + + *p = '\0'; + + // Copy safely into output buffer + strncpy(out, fen, out_sz); + out[out_sz - 1] = '\0'; } \ No newline at end of file -- 2.34.1 From d7d6263cc1096ffa4fe619c5eb842ef6b06d7755 Mon Sep 17 00:00:00 2001 From: Josh Date: Mon, 25 Aug 2025 20:22:31 -0400 Subject: [PATCH 7/9] Update evaluation classes --- scripts/evaluation.py | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/scripts/evaluation.py b/scripts/evaluation.py index e95251e..2a3e6c8 100644 --- a/scripts/evaluation.py +++ b/scripts/evaluation.py @@ -1,10 +1,12 @@ import random -import ctypes as C from binding.python_c_ffi import ChessFFI from binding.python_c_ffi import Move class BaseEvaluation: + NAME = "" + + def __init__(self, chess_ffi=None): if chess_ffi is None: chess_ffi = ChessFFI() @@ -27,6 +29,9 @@ class BaseEvaluation: a simplistic approach. """ class RandomEval(BaseEvaluation): + NAME = "random" + + def __init__(self, chess_ffi=None): super().__init__(chess_ffi) @@ -36,6 +41,9 @@ class RandomEval(BaseEvaluation): class NegaMaxEval(BaseEvaluation): + NAME = "negamax" + + def __init__(self, depth=6, cp_window=20, chess_ffi=None): super().__init__(chess_ffi) self.depth = depth @@ -57,4 +65,7 @@ class NegaMaxEval(BaseEvaluation): def get_params(self): - return [self.depth, self.window] \ No newline at end of file + return { + "depth": self.depth, + "window": self.window, + } \ No newline at end of file -- 2.34.1 From 1e56e77d254df8b0ffaacd8a9f4f4e09321e87f3 Mon Sep 17 00:00:00 2001 From: Josh Date: Mon, 25 Aug 2025 20:23:08 -0400 Subject: [PATCH 8/9] Add a chess move formatter --- scripts/format.py | 81 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 81 insertions(+) create mode 100644 scripts/format.py diff --git a/scripts/format.py b/scripts/format.py new file mode 100644 index 0000000..7846c0b --- /dev/null +++ b/scripts/format.py @@ -0,0 +1,81 @@ +import json +import os +from datetime import datetime + + +""" + A class to format a series of chess moves into specific formats for + storage. The base class expects a series of UCI moves in order. The + formatters format the UCI moves into specific output notations for + other systems to load and analyze. +""" +class BaseGameFormatter: + def __init__( + self, + save_path=None, + engine="", + white="", + black="", + result="", + strategies=None + ): + self.save_path = save_path + self.engine = engine + self.white = white + self.black = black + self.strategies = strategies + self.result = result + + self.formatted_moves = [] + self.fens = [] + + + def save_game(self, id, moves, fens): + self.formatted_moves = self.format_moves(moves) + self.fens = fens + self.save_to_disk(id) + + + def format_moves(self, moves): + """ + Define in child class. + """ + raise NotImplementedError + + + def save_to_disk(self, id): + date = datetime.today().strftime('%Y-%m-%d') + filename = f"{date}_{id}.json" + filepath = os.path.join(self.save_path, filename) + + + data = { + "date": datetime.today().strftime('%Y-%m-%d'), + "id": id, + "engine": "", + "white": "", + "black": "", + "strategies": self.strategies, + "result": self.result, + "len_moves": len(self.formatted_moves), + "moves": self.formatted_moves, + "fens": self.fens + } + + with open(filepath, "w") as out: + json.dump(data, out, indent=2) + + + def print_moves(self): + for move in self.formatted_moves: + print(move) + + +class LongPGNFormatter(BaseGameFormatter): + def format_moves(self, moves): + pgn_long = [] + it = iter(moves) + for white in it: + black = next(it, None) + pgn_long.append(f"{white} {black}" if black else white) + return pgn_long \ No newline at end of file -- 2.34.1 From 8de2a17fb8b492f46f4b5f269326427906cb89aa Mon Sep 17 00:00:00 2001 From: Josh Date: Mon, 25 Aug 2025 20:24:41 -0400 Subject: [PATCH 9/9] Update simulation class --- scripts/simulation.py | 105 +++++++++++++++++++++++++++--------------- 1 file changed, 69 insertions(+), 36 deletions(-) diff --git a/scripts/simulation.py b/scripts/simulation.py index f103d0d..d6de19f 100644 --- a/scripts/simulation.py +++ b/scripts/simulation.py @@ -1,3 +1,4 @@ +import uuid from binding.python_c_ffi import Board from binding.python_c_ffi import Move from binding.python_c_ffi import ChessFFI @@ -6,11 +7,12 @@ 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 +from scripts.format import LongPGNFormatter YMD_HM = "%Y-%m-%d-%H-%M" MAX_MOVES = 256 -DATA_PATH = "./data" +DATA_PATH = "./data/games" class Engine: @@ -19,11 +21,12 @@ class Engine: self.board = Board() self.max_plys = max_plys self.moves = [] + self.fens = [] 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: @@ -34,21 +37,33 @@ class Engine: else: self.strat_black = strat_black + self.strategies = { + "white": { + "name": self.strat_white.NAME, + "params": self.strat_white.get_params() + }, + "black": { + "name": self.strat_black.NAME, + "params": self.strat_black.get_params() + }, + } - def run(self, fen): + + def run(self, fen, save=True): self.chess_ffi.load_fen(self.board, fen) self._clear_moves() plys = 0 + self.fens.append(fen) 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) + game_over, result = self._is_game_over(n_legal) if game_over: - print(ending) + print(result) break legal = [moves_buf[i] for i in range(n_legal)] @@ -57,7 +72,6 @@ class Engine: 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") @@ -66,22 +80,31 @@ class Engine: self.board = new_board plys += 1 + fen_string = self.chess_ffi.board_to_fen(self.board) + self.fens.append(fen_string) + move = self.to_uci(best_move) self.moves.append(move) if self.board.halfmove_clock >= 100: print("draw (50-move rule)") + result = "1/2-1/2" 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)) + if save: + print("saving game to disk.") + id = uuid.uuid4().hex + + formatter = LongPGNFormatter( + save_path=DATA_PATH, + strategies=self.strategies, + result=result + ) + formatter.save_game(id, self.moves, self.fens) + def side_tag(self, side): return 'w' if side == WHITE else 'b' @@ -90,35 +113,32 @@ class Engine: 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}" + + # Only add a promotion letter if promo is set + promo_letter = "" + promo_val = int(getattr(move, "promo", 0) or 0) + if promo_val: + # Normalize piece id to type 0..5 (P,N,B,R,Q,K) + pt = promo_val % 6 + # Map N=1, B=2, R=3, Q=4 + letter_map = {1: "n", 2: "b", 3: "r", 4: "q"} + promo_letter = letter_map.get(pt, "") + + return f"{fr}{to}{promo_letter}" def _load_attack_cache(self): self.chess_ffi.init_attack_cache() + def _clear_moves(self): + self.moves = [] + + def _seed_engine(self): import ctypes as C, time @@ -135,15 +155,28 @@ class Engine: 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" + # Checkmate. + if self.board.side_to_move == WHITE: + result = "0-1" + else: + result = "1-0" else: - ending = "stalemate" - return True, ending + # Stalemate. + result = "1/2-1/2" + return True, result return False, None if __name__ == "__main__": - engine = Engine() + chess_ffi = ChessFFI() + nega_strat_1 = NegaMaxEval(chess_ffi=chess_ffi, depth=2, cp_window=5) + nega_strat_2 = NegaMaxEval(chess_ffi=chess_ffi, depth=1, cp_window=5) + + + engine = Engine( + strat_white=nega_strat_1, + strat_black=nega_strat_2, + ) fen = "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1" - engine.run(fen) \ No newline at end of file + engine.run(fen, save=True) \ No newline at end of file -- 2.34.1