20-add-negamax-eval-function (#31)
All checks were successful
Python tests (make) / test (push) Successful in 12s

Reviewed-on: #31
Co-authored-by: Josh <josh@joshuaschuett.com>
Co-committed-by: Josh <josh@joshuaschuett.com>
This commit is contained in:
2025-08-26 00:28:03 +00:00
committed by Josh
parent 6005741b10
commit 27d0f1b6e6
12 changed files with 622 additions and 24 deletions

3
.gitignore vendored
View File

@@ -1,2 +1,3 @@
build
__pycache__
data
__pycache__

View File

@@ -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,11 @@ 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),
"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)
}
@@ -79,7 +87,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):
@@ -123,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)()
@@ -131,6 +167,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)

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

View File

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

View File

@@ -1 +1,2 @@
int load_fen();
void board_to_fen(struct Board *board, char *out, size_t out_sz);

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

@@ -0,0 +1,115 @@
#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;
}

View File

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

View File

@@ -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';
}

View File

@@ -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/<path>/file.o from <path>/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
exit $$status

71
scripts/evaluation.py Normal file
View File

@@ -0,0 +1,71 @@
import random
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()
self.chess_ffi = chess_ffi
def get_best_move(self, board, legal_moves):
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
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):
NAME = "random"
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):
NAME = "negamax"
def __init__(self, depth=6, cp_window=20, chess_ffi=None):
super().__init__(chess_ffi)
self.depth = depth
self.window = cp_window
def get_best_move(self, board, legal_moves):
best = Move()
ok = self.chess_ffi._c_ai_find_best_move_with_window(
board,
self.depth,
self.window,
best
)
if ok:
return best
return legal_moves[0]
def get_params(self):
return {
"depth": self.depth,
"window": self.window,
}

81
scripts/format.py Normal file
View File

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

182
scripts/simulation.py Normal file
View File

@@ -0,0 +1,182 @@
import uuid
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
from scripts.format import LongPGNFormatter
YMD_HM = "%Y-%m-%d-%H-%M"
MAX_MOVES = 256
DATA_PATH = "./data/games"
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.fens = []
self._load_attack_cache()
self._seed_engine()
random_strat = RandomEval(chess_ffi=self.chess_ffi)
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
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, 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, result = self._is_game_over(n_legal)
if game_over:
print(result)
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
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
print("done")
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'
def load_fen(self, fen):
self.chess_ffi.load_fen(self.board, fen)
def to_uci(self, move):
fr = sq_to_coord(getattr(move, "from"))
to = sq_to_coord(move.to)
# 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
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):
# Checkmate.
if self.board.side_to_move == WHITE:
result = "0-1"
else:
result = "1-0"
else:
# Stalemate.
result = "1/2-1/2"
return True, result
return False, None
if __name__ == "__main__":
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, save=True)