Add some basic algorithms and eval functions for an AI
All checks were successful
Python tests (make) / test (push) Successful in 11s

This commit is contained in:
2025-08-23 13:10:05 -04:00
parent 60a987a7a1
commit 4fc982eac2
6 changed files with 409 additions and 21 deletions

View File

@@ -28,6 +28,9 @@ FILES = {c:i for i,c in enumerate("abcdefgh")}
WHITE, BLACK, BOTH = 0, 1, 2 WHITE, BLACK, BOTH = 0, 1, 2
P, N, B, R, Q, K, p, n, b, r, q, k = range(12) 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. 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), "perft": ((C.POINTER(Board), C.c_int), C.c_uint64),
"load_fen": ((C.POINTER(Board), C.c_char_p), C.c_int), "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), "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)
} }
@@ -82,6 +88,28 @@ def draw_bb(mask, origin=None):
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): def sq_to_coord(sq):
return chr(ord('a') + (sq % 8)) + chr(ord('1') + (sq // 8)) return chr(ord('a') + (sq % 8)) + chr(ord('1') + (sq // 8))
@@ -131,6 +159,10 @@ class ChessFFI:
return buf, n 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): def get_legal_moves(self, board, out):
return self._c_get_legal_moves(board, out) return self._c_get_legal_moves(board, out)

4
engine/include/ai.h Normal file
View File

@@ -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);

123
engine/src/ai/negamax.c Normal file
View File

@@ -0,0 +1,123 @@
#include <stdlib.h>
#include <time.h>
#include <limits.h>
#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;
}

View File

@@ -2,46 +2,64 @@ CC := gcc
CFLAGS := -O3 -fPIC -Wall -Wextra CFLAGS := -O3 -fPIC -Wall -Wextra
LDFLAGS := -shared LDFLAGS := -shared
SRCDIR := engine/src # ---- paths ----
INCDIR := engine/include SRCDIRS := engine/src engine/src/ai
INCDIRS := engine/include
BUILDDIR := build BUILDDIR := build
LIBNAME := libchess LIBNAME := libchess
LIB := $(BUILDDIR)/$(LIBNAME).so
# Exclude test.c from the shared lib build # ---- sources/objects ----
SRC := $(filter-out $(SRCDIR)/test.c,$(wildcard $(SRCDIR)/*.c)) EXCLUDE := engine/src/test.c
OBJ := $(patsubst $(SRCDIR)/%.c,$(BUILDDIR)/%.o,$(SRC)) SRCS := $(foreach d,$(SRCDIRS),$(wildcard $(d)/*.c))
LIB := $(BUILDDIR)/$(LIBNAME).so SRCS := $(filter-out $(EXCLUDE),$(SRCS))
OBJS := $(patsubst %.c,$(BUILDDIR)/%.o,$(SRCS))
# ---- includes ----
INCLUDES := $(addprefix -I,$(INCDIRS))
# ---- test executable (engine/src/test.c) ---- # ---- test executable (engine/src/test.c) ----
TESTSRC := $(SRCDIR)/test.c TESTSRC := engine/src/test.c
TESTOBJ := $(BUILDDIR)/test.o TESTOBJ := $(BUILDDIR)/engine/src/test.o
TESTBIN := $(BUILDDIR)/print_board 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) all: $(LIB)
$(BUILDDIR): # generic object rule: build/<path>/file.o from <path>/file.c
@mkdir -p $(BUILDDIR) $(BUILDDIR)/%.o: %.c
@mkdir -p $(dir $@)
$(CC) $(CFLAGS) $(INCLUDES) -c $< -o $@
$(BUILDDIR)/%.o: $(SRCDIR)/%.c | $(BUILDDIR) $(LIB): $(OBJS)
$(CC) $(CFLAGS) -I$(INCDIR) -c $< -o $@ @mkdir -p $(dir $@)
$(LIB): $(OBJ) | $(BUILDDIR)
$(CC) $(LDFLAGS) -o $@ $^ $(CC) $(LDFLAGS) -o $@ $^
# ---- test exe rules ---- # ---- test exe rules ----
test-exe: $(TESTBIN) test-exe: $(TESTBIN)
$(TESTOBJ): $(TESTSRC) | $(BUILDDIR) $(TESTOBJ): $(TESTSRC)
$(CC) $(CFLAGS) -I$(INCDIR) -c $< -o $@ @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' $(CC) -O2 -o $@ $(TESTOBJ) -L$(BUILDDIR) -lchess -Wl,-rpath,'$$ORIGIN'
run-c-test: $(TESTBIN) run-c-test: $(TESTBIN)
LD_LIBRARY_PATH=$(BUILDDIR) $(TESTBIN) $(FEN) 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: clean:
@echo "Cleaning build and Python caches..." @echo "Cleaning build and Python caches..."
@rm -rf $(BUILDDIR) @rm -rf $(BUILDDIR)

62
scripts/evaluation.py Normal file
View File

@@ -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]

149
scripts/simulation.py Normal file
View File

@@ -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)