From 2a9bbfb6e969d7daf2edd5218974fb7c945f9366 Mon Sep 17 00:00:00 2001 From: Don Kirkby Date: Sun, 24 Dec 2023 08:47:08 -0800 Subject: [PATCH] Calculate legal moves in Chess Golf, for #27. --- docs/journal/index.md | 15 +++ golf.py | 98 ++++++++++++++++ raw_rules/rules.md | 4 +- tests/test_golf.py | 254 ++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 369 insertions(+), 2 deletions(-) create mode 100644 golf.py create mode 100644 tests/test_golf.py diff --git a/docs/journal/index.md b/docs/journal/index.md index f8202ad..36a612e 100644 --- a/docs/journal/index.md +++ b/docs/journal/index.md @@ -49,3 +49,18 @@ solitaire. I also converted the scripts for publishing the Donimoes rules as PDF and website to this project. + +### Nov 2023 - Adrenaline Chess +Started with Manna Chess, where manna drops randomly after each capture, and +gives the piece carrying it one extra king move. Players found the random drops +annoying, so we switched to Adrenaline Chess, where you choose which opposing +piece to give an adrenaline boost to after each capture. Made for some +interesting tactics of giving the boost to a piece you could capture it back +from. + +### Dec 2023 - Chess Golf +Started with Neighbour Chess Solitaire, where the pieces borrow their +neighbour's move, and then merged it with Ricochet Robots to make Chess Golf. +Struggled with the mechanics of automatically adding pieces back to the board +and what to do when a colour has no neighbouring pairs. Fixed it by adding +special moves that can be done any time, but are expensive. \ No newline at end of file diff --git a/golf.py b/golf.py new file mode 100644 index 0000000..2fbe1e8 --- /dev/null +++ b/golf.py @@ -0,0 +1,98 @@ +import typing +from collections import Counter +from textwrap import dedent + +import chess + +from board_parser import parse_board + + +class GolfState: + def __init__(self, text: str): + board_lines = text.splitlines() + board_text = '\n'.join(board_lines[:8]) + self.board = parse_board(board_text) + self.taking = None + self.taken = Counter() + self.chosen = Counter() + for line in board_lines[8:]: + if line.startswith('chosen:'): + chosen_text = line[7:].strip() + self.chosen = Counter(chess.Piece.from_symbol(c) + for c in chosen_text) + elif line.startswith('taking:'): + self.taking = chess.parse_square(line[7:].strip()) + elif line.startswith('taken:'): + taken_text = line[6:].strip() + self.taken = Counter(chess.Piece.from_symbol(c) + for c in taken_text) + else: + label = line.split(':')[0] + raise ValueError(f'Unknown golf label: {label!r}.') + + def find_moves(self): + for square, piece in self.board.piece_map().items(): + board_copy = self.board.copy() + moving_piece = board_copy.piece_at(square) + can_capture = moving_piece in self.chosen + if can_capture and self.taking is not None: + can_capture = square == self.taking + if not can_capture: + target_counts = None + else: + target_counts = self.chosen - self.taken + target_counts[moving_piece] -= 1 + for neighbour_type in get_neighbour_types(self.board, square): + for turn in (chess.WHITE, chess.BLACK): + fake_moving_piece = chess.Piece(neighbour_type, turn) + board_copy.set_piece_at(square, fake_moving_piece) + board_copy.turn = turn + from_bitboard = 1 << square + for move in board_copy.generate_pseudo_legal_moves( + from_bitboard): + is_capture = board_copy.is_capture(move) + if not is_capture: + if turn == chess.BLACK: + continue + else: + if not can_capture: + continue + captured_piece = self.board.piece_at(move.to_square) + if target_counts[captured_piece] <= 0: + continue + + yield move + + +def get_neighbour_types(board: chess.Board, + square: chess.Square) -> typing.Set[chess.PieceType]: + neighbour_types = set() + king_board = chess.Board() + king_board.set_piece_at(square, chess.Piece(chess.KING, chess.WHITE)) + start_piece = board.piece_at(square) + for neighbour_square in king_board.attacks(square): + neighbour = board.piece_at(neighbour_square) + if neighbour is None: + continue + if neighbour.color != start_piece.color: + continue + neighbour_types.add(neighbour.piece_type) + return neighbour_types + + +def main(): + board = parse_board(dedent("""\ + . . B . . . R . + . . . . . k . . + . r . n . N . . + . r . . b . . . + R . . . . . q . + . B . n . N . . + b . . . . . . K + . . . . . Q . .""")) + for square, piece in board.piece_map().items(): + print(square, piece) + + +if __name__ == '__main__': + main() diff --git a/raw_rules/rules.md b/raw_rules/rules.md index 0e0ba4e..f65a08a 100644 --- a/raw_rules/rules.md +++ b/raw_rules/rules.md @@ -951,8 +951,8 @@ hearts and spades. Know some other lighthearted chess variants? Ideas to share? Get in touch at [https://donkirkby.github.io/chess-kit][github]. -Zombie Chess, Masquerade Chess, Crowded House, and Cooperative Chess are -original games designed by [Don Kirkby][don]. +Zombie Chess, Masquerade Chess, Chess Golf, Crowded House, and Cooperative Chess +are original games designed by [Don Kirkby][don]. [github]: https://donkirkby.github.io/chess-kit [don]: https://donkirkby.github.io/ diff --git a/tests/test_golf.py b/tests/test_golf.py new file mode 100644 index 0000000..8543ffd --- /dev/null +++ b/tests/test_golf.py @@ -0,0 +1,254 @@ +from collections import Counter +from textwrap import dedent + +import chess +import pytest + +from board_parser import parse_board +from golf import get_neighbour_types, GolfState + + +def test_get_neighbour_types(): + board = parse_board(dedent("""\ + . . B . . . R . + . . . . . . . . + . r . n . Q . . + . r . . b k . . + R . . . . . q . + . B . n . N . . + b . . . . . . K + . . . . . N . .""")) + expected_neighbour_types = {chess.KNIGHT, chess.KING} + + neighbour_types = get_neighbour_types(board, chess.E5) + + assert board.piece_at(chess.E5) == chess.Piece(chess.BISHOP, chess.BLACK) + assert neighbour_types == expected_neighbour_types + + +def test_new_golf_state(): + start_text = dedent("""\ + . . B . . . R . + . . . . . . . . + . r . n . Q . . + . r . . b k . . + R . . . . . q . + . B . n . N . . + b . . . . . . K + . . . . . N . . + chosen: Bbq""") + expected_board = parse_board(start_text.split('chosen')[0]) + + state = GolfState(start_text) + + assert state.board == expected_board + assert state.taking is None + assert state.taken == Counter() + assert state.chosen == Counter([chess.Piece(chess.BISHOP, chess.WHITE), + chess.Piece(chess.BISHOP, chess.BLACK), + chess.Piece(chess.QUEEN, chess.BLACK)]) + + +def test_captured_golf_state(): + start_text = dedent("""\ + . . B . . . R . + . . . . . . . . + . r . n . Q . . + . r . . b k . . + R . . . . . q . + . . . n . N . . + B . . . . . . K + . . . . . N . . + chosen: Bbq + taking: a2 + taken: b""") + + state = GolfState(start_text) + + assert state.taking == chess.A2 + assert state.taken == Counter([chess.Piece(chess.BISHOP, chess.BLACK)]) + assert state.chosen == Counter([chess.Piece(chess.BISHOP, chess.WHITE), + chess.Piece(chess.BISHOP, chess.BLACK), + chess.Piece(chess.QUEEN, chess.BLACK)]) + + +def test_bogus_golf_state(): + start_text = dedent("""\ + . . B . . . R . + . . . . . . . . + . r . n . Q . . + . r . . b k . . + R . . . . . q . + . . . n . N . . + B . . . . . . K + . . . . . N . . + bogus: Bbq""") + + with pytest.raises(ValueError, match=r"Unknown golf label: 'bogus'."): + GolfState(start_text) + + +def test_find_moves_not_chosen_taker(): + state = GolfState(dedent("""\ + n k . . . . R N + . . . B r . n . + . . q . . B . . + . . . . . . . b + . . . . . . . . + . . . . . . . . + . . . . . . . . + . . . . . . . . + chosen: Bb""")) + + expected_moves = {chess.Move(chess.A8, chess.A7), # knight uses king + chess.Move(chess.A8, chess.B7), + chess.Move(chess.B8, chess.A6), # king uses knight + chess.Move(chess.G8, chess.H6), # rook uses knight + chess.Move(chess.H8, chess.H7), # knight uses rook + chess.Move(chess.H8, chess.H6)} + + moves = list(state.find_moves()) + + assert len(moves) == len(expected_moves) + assert set(moves) == expected_moves + + +# noinspection DuplicatedCode +def test_find_moves_not_chosen_taken(): + state = GolfState(dedent("""\ + . . . . . . R B + . . . . . b . . + . . . . . . . . + . . . . . . . n + . . . . . . . . + . . . . . . . . + . . . . . . . . + . . . . . . . . + chosen: Bb""")) + + expected_moves = {chess.Move(chess.H8, chess.H7), # bishop uses rook + chess.Move(chess.H8, chess.H6), + chess.Move(chess.G8, chess.H7)} # rook uses bishop + + moves = list(state.find_moves()) + + assert len(moves) == len(expected_moves) + assert set(moves) == expected_moves + + +# noinspection DuplicatedCode +def test_find_moves_both_chosen(): + state = GolfState(dedent("""\ + . . . . . . R B + . . . . . n . . + . . . . . . . . + . . . . . . . b + . . . . . . . . + . . . . . . . . + . . . . . . . . + . . . . . . . . + chosen: Bb""")) + + expected_moves = {chess.Move(chess.H8, chess.H7), # bishop uses rook + chess.Move(chess.H8, chess.H6), + chess.Move(chess.H8, chess.H5), + chess.Move(chess.G8, chess.H7)} # rook uses bishop + + moves = list(state.find_moves()) + + assert len(moves) == len(expected_moves) + assert set(moves) == expected_moves + + +# noinspection DuplicatedCode +def test_find_moves_same_type_not_allowed(): + state = GolfState(dedent("""\ + . . . . . . R B + . . . . . b . . + . . . . . . . . + . . . . . . . B + . . . . . . . . + . . . . . . . . + . . . . . . . . + . . . . . . . . + chosen: Bb""")) + + expected_moves = {chess.Move(chess.H8, chess.H7), # bishop uses rook + chess.Move(chess.H8, chess.H6), + chess.Move(chess.G8, chess.H7)} # rook uses bishop + + moves = list(state.find_moves()) + + assert len(moves) == len(expected_moves) + assert set(moves) == expected_moves + + +# noinspection DuplicatedCode +def test_find_moves_same_type_allowed(): + state = GolfState(dedent("""\ + . . . . . . R B + . . . . . b . . + . . . . . . . . + . . . . . . . B + . . . . . . . . + . . . . . . . . + . . . . . . . . + . . . . . . . . + chosen: BB""")) + + expected_moves = {chess.Move(chess.H8, chess.H7), # bishop uses rook + chess.Move(chess.H8, chess.H6), + chess.Move(chess.H8, chess.H5), + chess.Move(chess.G8, chess.H7)} # rook uses bishop + + moves = list(state.find_moves()) + + assert len(moves) == len(expected_moves) + assert set(moves) == expected_moves + + +# noinspection DuplicatedCode +def test_find_moves_single_piece_may_capture(): + state = GolfState(dedent("""\ + B R . . . . R B + n . q . . k . n + . . . . . . . . + . . . . . . . . + . . . . . . . . + . . . . . . . . + . . . . . . . . + . . . . . . . . + chosen: Bbn + taking: a8 + taken: b""")) + + expected_moves = {chess.Move(chess.A8, chess.A7)} # bishop uses rook + + moves = list(state.find_moves()) + + assert len(moves) == len(expected_moves) + assert set(moves) == expected_moves + + +# noinspection DuplicatedCode +def test_find_moves(): + state = GolfState(dedent("""\ + B R . . . . . . + r . . . . . . . + . . . k . . . . + . . . . . . . . + . . . . . q . . + . . . . K . . . + . . . . . . b . + . . . . Q . . n + chosen: Bbr + taking: a8 + taken: r""")) + + expected_moves = {chess.Move(chess.B8, chess.C7), # rook uses bishop + chess.Move(chess.G2, chess.H4)} # bishop uses knight + + moves = list(state.find_moves()) + + assert len(moves) == len(expected_moves) + assert set(moves) == expected_moves