Add chess GUI and move generation

This commit is contained in:
2026-05-19 16:05:53 -08:00
parent b36f5b0b84
commit 4131c93f99
69 changed files with 80468 additions and 218 deletions
+600
View File
@@ -0,0 +1,600 @@
const std = @import("std");
const piece = @import("piece.zig");
const board = @import("board.zig");
pub fn parseFen(fen: []const u8) !board.BoardState {
var state = board.BoardState.empty();
var it = std.mem.splitScalar(u8, fen, ' ');
var field_index: usize = 0;
while (it.next()) |field| : (field_index += 1) {
if (field_index >= 6) return error.InvalidFenFieldCount;
switch (field_index) {
0 => try parseBoardPlacement(&state, field),
1 => try parseTurn(&state, field),
2 => try parseCastleRights(&state, field),
3 => try parseEnPassant(&state, field),
4 => try parseHalfmove(&state, field),
5 => try parseFullmove(&state, field),
else => unreachable,
}
}
if (field_index != 6) return error.InvalidFenFieldCount;
return state;
}
pub fn parseBoardPlacement(state: *board.BoardState, placement: []const u8) !void {
var it = std.mem.splitScalar(u8, placement, '/');
var rank_count: u4 = 0;
while (it.next()) |part| {
if (rank_count >= 8) return error.InvalidRankCount;
const rank: u3 = @intCast(7 - rank_count);
var file: u4 = 0;
for (part) |c| {
switch (c) {
'1'...'9' => {
file += @intCast(c - '0');
if (file > 8) return error.InvalidRankWidth;
},
'P',
'N',
'B',
'R',
'Q',
'K',
'p',
'n',
'b',
'r',
'q',
'k',
=> {
if (file >= 8) return error.InvalidRankWidth;
const p = try piece.fromFENChar(c);
state.setSquare(@intCast(file), rank, p);
file += 1;
},
else => return error.InvalidFenCharacter,
}
}
if (file != 8) return error.InvalidRankWidth;
rank_count += 1;
}
if (rank_count != 8) return error.InvalidRankCount;
}
pub fn parseTurn(state: *board.BoardState, turn_string: []const u8) !void {
if (turn_string.len != 1) return error.InvalidTurnChar;
const turn_char = turn_string[0];
if (turn_char == 'w') {
state.turn = piece.Color.white;
} else if (turn_char == 'b') {
state.turn = piece.Color.black;
} else {
return error.InvalidTurnChar;
}
}
pub fn parseHalfmove(state: *board.BoardState, halfmove_string: []const u8) !void {
state.halfmove = try std.fmt.parseInt(u8, halfmove_string, 10);
}
pub fn parseFullmove(state: *board.BoardState, fullmove_string: []const u8) !void {
const fullmove = try std.fmt.parseInt(u32, fullmove_string, 10);
if (fullmove == 0) return error.InvalidFullmoveNumber;
state.fullmove = fullmove;
}
pub fn parseCastleRights(state: *board.BoardState, castle_string: []const u8) !void {
if (std.mem.eql(u8, castle_string, "")) return error.InvalidCastlingRights;
if (std.mem.eql(u8, castle_string, "-")) {
state.castle_rights = 0;
return;
}
var rights: u4 = 0;
var last_order: u3 = 0;
for (castle_string) |c| {
const bit: u4 = switch (c) {
'K' => 0b1000,
'Q' => 0b0100,
'k' => 0b0010,
'q' => 0b0001,
else => return error.InvalidCastlingRights,
};
const order: u3 = switch (c) {
'K' => 1,
'Q' => 2,
'k' => 3,
'q' => 4,
else => unreachable,
};
if (order <= last_order) {
return error.InvalidCastlingRights;
}
if ((rights & bit) != 0) {
return error.InvalidCastlingRights;
}
rights |= bit;
last_order = order;
}
state.castle_rights = rights;
}
pub fn isEnPassantCapturable(state: *board.BoardState, ep_square: u6) bool {
const file: u3 = @intCast(ep_square % 8);
const rank: u3 = @intCast((ep_square / 8) + 1);
var pawn_rank: u3 = 0;
if (state.turn == piece.Color.white) {
if (rank != 6) return false;
pawn_rank = 4;
} else {
if (rank != 3) return false;
pawn_rank = 3;
}
var p: u4 = 0;
if (file == 0) {
p = state.getSquare(file + 1, pawn_rank);
if (piece.typeOf(p) == piece.PieceType.pawn and piece.colorOf(p) == state.turn) {
return true;
}
} else if (file == 7) {
p = state.getSquare(file - 1, pawn_rank);
if (piece.typeOf(p) == piece.PieceType.pawn and piece.colorOf(p) == state.turn) {
return true;
}
} else {
p = state.getSquare(file - 1, pawn_rank);
if (piece.typeOf(p) == piece.PieceType.pawn and piece.colorOf(p) == state.turn) {
return true;
}
p = state.getSquare(file + 1, pawn_rank);
if (piece.typeOf(p) == piece.PieceType.pawn and piece.colorOf(p) == state.turn) {
return true;
}
}
return false;
}
pub fn parseEnPassant(state: *board.BoardState, ep_string: []const u8) !void {
if (std.mem.eql(u8, ep_string, "-")) {
state.en_passant = 0;
return;
}
if (ep_string.len != 2) return error.InvalidSquare;
const ep_square = try board.parseSquareFromAlgebraic(ep_string);
if (ep_string[1] != '3' and ep_string[1] != '6') return error.InvalidEnPassantSquare;
if (isEnPassantCapturable(state, ep_square)) {
state.en_passant = (1 << 6) | @as(u7, ep_square);
} else {
state.en_passant = 0;
}
}
fn pieceToFenChar(encoded: u4) !u8 {
const color = piece.colorOf(encoded) orelse return error.InvalidPiece;
return switch (piece.typeOf(encoded)) {
.pawn => if (color == .white) 'P' else 'p',
.knight => if (color == .white) 'N' else 'n',
.bishop => if (color == .white) 'B' else 'b',
.rook => if (color == .white) 'R' else 'r',
.queen => if (color == .white) 'Q' else 'q',
.king => if (color == .white) 'K' else 'k',
.none => error.InvalidPiece,
};
}
fn appendBoardPlacement(allocator: std.mem.Allocator, out: *std.ArrayList(u8), state: board.BoardState) !void {
var rank: u4 = 8;
while (rank > 0) {
rank -= 1;
var empty_count: u4 = 0;
var file: u4 = 0;
while (file < 8) : (file += 1) {
const square = state.getSquare(@intCast(file), @intCast(rank));
if (square == 0) {
empty_count += 1;
continue;
}
if (empty_count > 0) {
try out.append(allocator, '0' + @as(u8, @intCast(empty_count)));
empty_count = 0;
}
try out.append(allocator, try pieceToFenChar(square));
}
if (empty_count > 0) {
try out.append(allocator, '0' + @as(u8, @intCast(empty_count)));
}
if (rank > 0) try out.append(allocator, '/');
}
}
fn appendCastleRights(allocator: std.mem.Allocator, out: *std.ArrayList(u8), rights: u4) !void {
if (rights == 0) {
try out.append(allocator, '-');
return;
}
if ((rights & 0b1000) != 0) try out.append(allocator, 'K');
if ((rights & 0b0100) != 0) try out.append(allocator, 'Q');
if ((rights & 0b0010) != 0) try out.append(allocator, 'k');
if ((rights & 0b0001) != 0) try out.append(allocator, 'q');
}
fn appendEnPassant(allocator: std.mem.Allocator, out: *std.ArrayList(u8), en_passant: u7) !void {
const valid = (en_passant & (@as(u7, 1) << 6)) != 0;
if (!valid) {
try out.append(allocator, '-');
return;
}
const square = en_passant & 0b111111;
const file: u8 = @intCast(square % 8);
const rank: u8 = @intCast(square / 8);
try out.append(allocator, 'a' + file);
try out.append(allocator, '1' + rank);
}
pub fn formatFen(allocator: std.mem.Allocator, state: board.BoardState) ![]u8 {
var out: std.ArrayList(u8) = .empty;
errdefer out.deinit(allocator);
try appendBoardPlacement(allocator, &out, state);
try out.append(allocator, ' ');
try out.append(allocator, if (state.turn == .white) 'w' else 'b');
try out.append(allocator, ' ');
try appendCastleRights(allocator, &out, state.castle_rights);
try out.append(allocator, ' ');
try appendEnPassant(allocator, &out, state.en_passant);
try out.append(allocator, ' ');
var halfmove_buf: [3]u8 = undefined;
const halfmove = try std.fmt.bufPrint(&halfmove_buf, "{}", .{state.halfmove});
try out.appendSlice(allocator, halfmove);
try out.append(allocator, ' ');
var fullmove_buf: [10]u8 = undefined;
const fullmove = try std.fmt.bufPrint(&fullmove_buf, "{}", .{state.fullmove});
try out.appendSlice(allocator, fullmove);
return try out.toOwnedSlice(allocator);
}
test "parseBoardPlacement parses starting position" {
var state = board.BoardState.empty();
try parseBoardPlacement(
&state,
"rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR",
);
try std.testing.expectEqual(@as(u32, 0xCABEDBAC), state.board[0]);
try std.testing.expectEqual(@as(u32, 0x99999999), state.board[1]);
try std.testing.expectEqual(@as(u32, 0x00000000), state.board[2]);
try std.testing.expectEqual(@as(u32, 0x00000000), state.board[3]);
try std.testing.expectEqual(@as(u32, 0x00000000), state.board[4]);
try std.testing.expectEqual(@as(u32, 0x00000000), state.board[5]);
try std.testing.expectEqual(@as(u32, 0x11111111), state.board[6]);
try std.testing.expectEqual(@as(u32, 0x42365324), state.board[7]);
}
test "parseBoardPlacement rejects invalid rank counts" {
var state = board.BoardState.empty();
try std.testing.expectError(
error.InvalidRankCount,
parseBoardPlacement(&state, "8/8/8/8/8/8/8"),
);
try std.testing.expectError(
error.InvalidRankCount,
parseBoardPlacement(&state, "8/8/8/8/8/8/8/8/8"),
);
}
test "parseBoardPlacement rejects ranks that are not exactly eight squares" {
var state = board.BoardState.empty();
try std.testing.expectError(
error.InvalidRankWidth,
parseBoardPlacement(&state, "7/8/8/8/8/8/8/8"),
);
try std.testing.expectError(
error.InvalidRankWidth,
parseBoardPlacement(&state, "9/8/8/8/8/8/8/8"),
);
try std.testing.expectError(
error.InvalidRankWidth,
parseBoardPlacement(&state, "8P/8/8/8/8/8/8/8"),
);
}
test "parseTurn accepts white and black active colors" {
var state = board.BoardState.empty();
try parseTurn(&state, "b");
try std.testing.expectEqual(piece.Color.black, state.turn);
try parseTurn(&state, "w");
try std.testing.expectEqual(piece.Color.white, state.turn);
}
test "parseTurn rejects invalid active color" {
var state = board.BoardState.empty();
try std.testing.expectError(error.InvalidTurnChar, parseTurn(&state, "x"));
try std.testing.expectError(error.InvalidTurnChar, parseTurn(&state, "white"));
}
test "parseHalfmove parses decimal halfmove clock" {
var state = board.BoardState.empty();
try parseHalfmove(&state, "42");
try std.testing.expectEqual(@as(u8, 42), state.halfmove);
}
test "parseHalfmove rejects invalid or overflowing values" {
var state = board.BoardState.empty();
try std.testing.expectError(error.InvalidCharacter, parseHalfmove(&state, "abc"));
try std.testing.expectError(error.Overflow, parseHalfmove(&state, "256"));
}
test "parseFullmove parses decimal fullmove number" {
var state = board.BoardState.empty();
try parseFullmove(&state, "1");
try std.testing.expectEqual(@as(u32, 1), state.fullmove);
try parseFullmove(&state, "300");
try std.testing.expectEqual(@as(u32, 300), state.fullmove);
}
test "parseFullmove rejects zero and invalid values" {
var state = board.BoardState.empty();
try std.testing.expectError(error.InvalidFullmoveNumber, parseFullmove(&state, "0"));
try std.testing.expectError(error.InvalidCharacter, parseFullmove(&state, "abc"));
}
test "parseCastleRights parses no castling rights" {
var state = board.BoardState.empty();
try parseCastleRights(&state, "-");
try std.testing.expectEqual(@as(u4, 0b0000), state.castle_rights);
}
test "parseCastleRights parses individual and combined rights" {
var state = board.BoardState.empty();
try parseCastleRights(&state, "K");
try std.testing.expectEqual(@as(u4, 0b1000), state.castle_rights);
try parseCastleRights(&state, "Q");
try std.testing.expectEqual(@as(u4, 0b0100), state.castle_rights);
try parseCastleRights(&state, "k");
try std.testing.expectEqual(@as(u4, 0b0010), state.castle_rights);
try parseCastleRights(&state, "q");
try std.testing.expectEqual(@as(u4, 0b0001), state.castle_rights);
try parseCastleRights(&state, "KQkq");
try std.testing.expectEqual(@as(u4, 0b1111), state.castle_rights);
try parseCastleRights(&state, "Kq");
try std.testing.expectEqual(@as(u4, 0b1001), state.castle_rights);
try parseCastleRights(&state, "kq");
try std.testing.expectEqual(@as(u4, 0b0011), state.castle_rights);
}
test "parseCastleRights rejects invalid castling rights" {
var state = board.BoardState.empty();
try std.testing.expectError(error.InvalidCastlingRights, parseCastleRights(&state, ""));
try std.testing.expectError(error.InvalidCastlingRights, parseCastleRights(&state, "K-"));
try std.testing.expectError(error.InvalidCastlingRights, parseCastleRights(&state, "A"));
try std.testing.expectError(error.InvalidCastlingRights, parseCastleRights(&state, "KK"));
try std.testing.expectError(error.InvalidCastlingRights, parseCastleRights(&state, "qK"));
}
test "parseEnPassant parses no en passant target" {
var state = board.BoardState.empty();
try parseEnPassant(&state, "-");
try std.testing.expectEqual(@as(u7, 0), state.en_passant);
}
test "parseEnPassant stores zero when target is not capturable" {
var state = board.BoardState.empty();
state.turn = .black;
try parseEnPassant(&state, "e3");
try std.testing.expectEqual(@as(u7, 0), state.en_passant);
state.turn = .white;
try parseEnPassant(&state, "e6");
try std.testing.expectEqual(@as(u7, 0), state.en_passant);
}
test "parseEnPassant stores rank 6 target capturable by white pawn" {
var state = board.BoardState.empty();
state.turn = .white;
state.setSquare(3, 4, piece.encode(.white, .pawn)); // d5 can capture e6
try parseEnPassant(&state, "e6");
try std.testing.expectEqual(@as(u7, 108), state.en_passant);
}
test "parseEnPassant stores rank 3 target capturable by black pawn" {
var state = board.BoardState.empty();
state.turn = .black;
state.setSquare(5, 3, piece.encode(.black, .pawn)); // f4 can capture e3
try parseEnPassant(&state, "e3");
try std.testing.expectEqual(@as(u7, 84), state.en_passant);
}
test "parseEnPassant handles capturable edge-file targets" {
var white_state = board.BoardState.empty();
white_state.turn = .white;
white_state.setSquare(1, 4, piece.encode(.white, .pawn)); // b5 can capture a6
try parseEnPassant(&white_state, "a6");
try std.testing.expectEqual(@as(u7, 104), white_state.en_passant);
var black_state = board.BoardState.empty();
black_state.turn = .black;
black_state.setSquare(6, 3, piece.encode(.black, .pawn)); // g4 can capture h3
try parseEnPassant(&black_state, "h3");
try std.testing.expectEqual(@as(u7, 87), black_state.en_passant);
}
test "parseEnPassant ignores adjacent pawn of wrong color" {
var state = board.BoardState.empty();
state.turn = .white;
state.setSquare(3, 4, piece.encode(.black, .pawn));
try parseEnPassant(&state, "e6");
try std.testing.expectEqual(@as(u7, 0), state.en_passant);
}
test "parseEnPassant rejects invalid target squares" {
var state = board.BoardState.empty();
try std.testing.expectError(error.InvalidSquare, parseEnPassant(&state, ""));
try std.testing.expectError(error.InvalidSquare, parseEnPassant(&state, "e"));
try std.testing.expectError(error.InvalidSquare, parseEnPassant(&state, "i3"));
try std.testing.expectError(error.InvalidSquare, parseEnPassant(&state, "e9"));
}
test "parseEnPassant rejects target squares outside ranks 3 and 6" {
var state = board.BoardState.empty();
try std.testing.expectError(error.InvalidEnPassantSquare, parseEnPassant(&state, "e2"));
try std.testing.expectError(error.InvalidEnPassantSquare, parseEnPassant(&state, "e4"));
try std.testing.expectError(error.InvalidEnPassantSquare, parseEnPassant(&state, "e5"));
try std.testing.expectError(error.InvalidEnPassantSquare, parseEnPassant(&state, "e7"));
}
test "parseFen parses starting position" {
const state = try parseFen("rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1");
try std.testing.expectEqual(@as(u32, 0xCABEDBAC), state.board[0]);
try std.testing.expectEqual(@as(u32, 0x99999999), state.board[1]);
try std.testing.expectEqual(@as(u32, 0x00000000), state.board[2]);
try std.testing.expectEqual(@as(u32, 0x00000000), state.board[3]);
try std.testing.expectEqual(@as(u32, 0x00000000), state.board[4]);
try std.testing.expectEqual(@as(u32, 0x00000000), state.board[5]);
try std.testing.expectEqual(@as(u32, 0x11111111), state.board[6]);
try std.testing.expectEqual(@as(u32, 0x42365324), state.board[7]);
try std.testing.expectEqual(piece.Color.white, state.turn);
try std.testing.expectEqual(@as(u4, 0b1111), state.castle_rights);
try std.testing.expectEqual(@as(u7, 0), state.en_passant);
try std.testing.expectEqual(@as(u8, 0), state.halfmove);
try std.testing.expectEqual(@as(u32, 1), state.fullmove);
}
test "parseFen parses capturable en passant position" {
const state = try parseFen("8/8/8/3Pp3/8/8/8/8 w - e6 0 1");
try std.testing.expectEqual(piece.Color.white, state.turn);
try std.testing.expectEqual(@as(u4, 0), state.castle_rights);
try std.testing.expectEqual(@as(u7, 108), state.en_passant);
try std.testing.expectEqual(piece.encode(.white, .pawn), state.getSquare(3, 4));
try std.testing.expectEqual(piece.encode(.black, .pawn), state.getSquare(4, 4));
}
test "parseFen stores zero for non-capturable en passant target" {
const state = try parseFen("r1bqkbnr/pppp1ppp/2n5/4p3/3P4/5N2/PPP2PPP/RNBQKB1R w KQkq e3 4 5");
try std.testing.expectEqual(piece.Color.white, state.turn);
try std.testing.expectEqual(@as(u4, 0b1111), state.castle_rights);
try std.testing.expectEqual(@as(u7, 0), state.en_passant);
try std.testing.expectEqual(@as(u8, 4), state.halfmove);
try std.testing.expectEqual(@as(u32, 5), state.fullmove);
try std.testing.expectEqual(piece.encode(.white, .pawn), state.getSquare(3, 3));
try std.testing.expectEqual(piece.encode(.black, .pawn), state.getSquare(4, 4));
}
test "parseFen rejects invalid field counts" {
try std.testing.expectError(
error.InvalidFenFieldCount,
parseFen("8/8/8/8/8/8/8/8 w - - 0"),
);
try std.testing.expectError(
error.InvalidFenFieldCount,
parseFen("8/8/8/8/8/8/8/8 w - - 0 1 extra"),
);
}
test "formatFen formats starting position" {
const expected = "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1";
const state = try parseFen(expected);
const actual = try formatFen(std.testing.allocator, state);
defer std.testing.allocator.free(actual);
try std.testing.expectEqualStrings(expected, actual);
}
test "formatFen formats capturable en passant target" {
const expected = "8/8/8/3Pp3/8/8/8/8 w - e6 0 1";
const state = try parseFen(expected);
const actual = try formatFen(std.testing.allocator, state);
defer std.testing.allocator.free(actual);
try std.testing.expectEqualStrings(expected, actual);
}
test "formatFen formats non-capturable en passant as dash" {
const input = "r1bqkbnr/pppp1ppp/2n5/4p3/3P4/5N2/PPP2PPP/RNBQKB1R w KQkq e3 4 5";
const expected = "r1bqkbnr/pppp1ppp/2n5/4p3/3P4/5N2/PPP2PPP/RNBQKB1R w KQkq - 4 5";
const state = try parseFen(input);
const actual = try formatFen(std.testing.allocator, state);
defer std.testing.allocator.free(actual);
try std.testing.expectEqualStrings(expected, actual);
}
test "formatFen formats black turn no castling and larger counters" {
var state = board.BoardState.empty();
state.turn = .black;
state.castle_rights = 0;
state.halfmove = 42;
state.fullmove = 300;
state.setSquare(4, 0, piece.encode(.white, .king));
state.setSquare(4, 7, piece.encode(.black, .king));
state.setSquare(0, 0, piece.encode(.white, .rook));
state.setSquare(7, 7, piece.encode(.black, .rook));
const actual = try formatFen(std.testing.allocator, state);
defer std.testing.allocator.free(actual);
try std.testing.expectEqualStrings("4k2r/8/8/8/8/8/8/R3K3 b - - 42 300", actual);
}