chessai.chess.gamestate

  1import typing
  2
  3import edq.util.serial
  4
  5import chessai.chess.piece
  6import chessai.core.action
  7import chessai.core.gamestate
  8
  9class GameState(chessai.core.gamestate.GameState):
 10    """ A game state specific to a standard Pacman game. """
 11
 12    def game_complete(self) -> tuple[list[chessai.core.types.Color], float]:
 13        winners = self.get_winners()
 14
 15        # Score is based on white's perspective using standard chess scoring.
 16        if (chessai.core.types.Color.WHITE in winners):
 17            score = 1.0
 18        elif (chessai.core.types.Color.BLACK in winners):
 19            score = 0.0
 20        else:
 21            score = 0.5
 22
 23        return (winners, score)
 24
 25    def is_check(self, color: chessai.core.types.Color, started_in_check: bool = True) -> bool:
 26        # Find the king of the given color.
 27        king_coordinate = self.get_king_coordinate(color)
 28
 29        # If there is no King, they cannot be in check.
 30        # This should never happen in standard chess games with valid positions.
 31        if (king_coordinate is None):
 32            return False
 33
 34        # Get the most recent action to try to avoid work when not doing a full check.
 35        previous_action = self.get_previous_action()
 36
 37        if ((not started_in_check) and isinstance(previous_action, chessai.core.action.MoveAction)):
 38            check_potential = self._has_check_potential(king_coordinate, previous_action)
 39        else:
 40            check_potential = True
 41
 42        # Skip checking moves that could not have opened us to check.
 43        if (not check_potential):
 44            return False
 45
 46        # Check if any opponent piece can attack the king's coordinate.
 47        old_turn = self.turn
 48        self.turn = color.opposite()
 49
 50        try:
 51            opponent_actions = self._get_pseudo_legal_moves()
 52            for action in opponent_actions:
 53                if (action.end_coordinate == king_coordinate):
 54                    return True
 55
 56            return False
 57        finally:
 58            self.turn = old_turn
 59
 60    def get_king_coordinate(self, color: chessai.core.types.Color) -> chessai.core.coordinate.Coordinate | None:
 61        """ Get the coordinate of the king for the given color. """
 62
 63        for (file, rank_dict) in self.board.pieces.items():
 64            for (rank, piece) in rank_dict.items():
 65                if ((piece.color == color) and (isinstance(piece, chessai.chess.piece.King))):
 66                    return chessai.core.coordinate.Coordinate(file, rank)
 67
 68        # If there's no king, the position is invalid, which should never happen.
 69        return None
 70
 71    def _has_check_potential(self, king_coordinate: chessai.core.coordinate.Coordinate, previous_action: chessai.core.action.MoveAction) -> bool:
 72        """
 73        Returns whether the player who played the most recent move could be in check.
 74
 75        Assumes the player was not in check before playing their most recent move.
 76        """
 77
 78        # Check if the king just moved, which means it could have moved into check.
 79        if (previous_action.end_coordinate == king_coordinate):
 80            return True
 81
 82        # Check if a piece moved on the king's file, rank, or diagonal.
 83        # This move would allow for discovery checks.
 84        if ((previous_action.start_coordinate.file == king_coordinate.file) or (previous_action.start_coordinate.rank == king_coordinate.rank)):
 85            # A piece moved on the king's rank or file, so the king could be under attack from a rook or queen.
 86            return True
 87
 88        # Check the diagonal.
 89        file_delta = abs(king_coordinate.file - previous_action.start_coordinate.file)
 90        rank_delta = abs(king_coordinate.rank - previous_action.start_coordinate.rank)
 91
 92        # If a piece moved away from the king's diagonal, the king could be under attack from a bishop or queen.
 93        return file_delta == rank_delta
 94
 95    def _should_reset_halfmove_clock(self, action: chessai.core.action.Action, piece: chessai.core.piece.Piece) -> bool:
 96        return (isinstance(piece, chessai.chess.piece.Pawn))
 97
 98    def _get_pseudo_legal_moves(self) -> list[chessai.core.action.MoveAction]:
 99        # Get the base movement from all pieces.
100        actions = self._get_base_move_actions()
101
102        # Add any castling moves.
103        actions.extend(self._get_castling_moves())
104
105        # Add any pawn double pushes.
106        actions.extend(self._get_pawn_double_pushes())
107
108        # Add any en-passant captures.
109        actions.extend(self._get_en_passant_captures())
110
111        return actions
112
113    def _get_base_move_actions(self) -> list[chessai.core.action.MoveAction]:
114        """
115        Expands all movement vectors from the pieces on the board.
116        Pieces will move until they can capture or reach the end of the board.
117
118        Detects when pawns reach the end of the board and can be promoted.
119        """
120
121        actions: list[chessai.core.action.MoveAction] = []
122
123        # Determine the rank for pawn promotions.
124        if (self.turn == chessai.core.types.Color.WHITE):
125            promotion_rank = self.board.num_ranks - 1
126        else:
127            promotion_rank = 0
128
129        for (file, rank_dict) in self.board.pieces.items():
130            for (rank, piece) in rank_dict.items():
131                if (piece.color != self.turn):
132                    continue
133
134                for movement_vector in piece.move_vectors():
135                    current_rank = rank
136                    current_file = file
137
138                    coordinate = chessai.core.coordinate.Coordinate(file, rank)
139
140                    num_repetitions = movement_vector.num_repetitions
141                    while (num_repetitions != 0):
142                        current_file += movement_vector.file_delta
143                        current_rank += movement_vector.rank_delta
144                        if (not self.board.is_within_bounds(current_file, current_rank)):
145                            break
146
147                        occupant = self.board.get(current_file, current_rank)
148
149                        is_occupied = occupant is not None
150                        is_enemy    = (occupant is not None) and (occupant.color != piece.color)
151                        is_ally     = (occupant is not None) and (occupant.color == piece.color)
152
153                        # No movement type can move on top of an ally.
154                        if (is_ally):
155                            break
156
157                        # Push movement types cannot capture.
158                        if ((movement_vector.kind == chessai.core.piece.MoveKind.PUSH) and is_occupied):
159                            break
160
161                        # Capture movement types must target an enemy.
162                        if ((movement_vector.kind == chessai.core.piece.MoveKind.CAPTURE) and (not is_enemy)):
163                            break
164
165                        current_coordinate = chessai.core.coordinate.Coordinate(current_file, current_rank)
166
167                        # Check if this action would lead to a pawn promotion.
168                        if ((isinstance(piece, chessai.chess.piece.Pawn)) and (current_coordinate.rank == promotion_rank)):
169                            actions.extend(self._get_promotion_actions(coordinate, current_coordinate))
170                        else:
171                            actions.append(chessai.core.action.MoveAction(coordinate, current_coordinate))
172
173                        if (is_occupied):
174                            break
175
176                        num_repetitions -= 1
177
178        return actions
179
180    def _process_special_move(self,
181                              action: chessai.core.action.Action,
182                              piece: chessai.core.piece.Piece) -> tuple[bool, chessai.core.coordinate.Coordinate | None]:
183        if (not isinstance(action, chessai.core.action.MoveAction)):
184            return False, None
185
186        # Update castling rights.
187        self._update_castling_rights(action, piece)
188
189        # Handle promoting pieces.
190        if isinstance(action, chessai.core.action.PromotionAction):
191            self._handle_promotion(action, piece)
192
193        # Detect castling by seeing if the king moved more than two files.
194        if ((isinstance(piece, chessai.chess.piece.King))
195                and (action.start_coordinate.file_distance(action.end_coordinate) > 1)):
196            self._handle_castling(action, piece)
197            return False, None
198
199        # Detect en-passant by checking if a pawn moves diagonally to an empty coordinate.
200        if ((isinstance(piece, chessai.chess.piece.Pawn))
201                and (action.start_coordinate.file_distance(action.end_coordinate) == 1)
202                and (self.get(action.end_coordinate) is None)):
203            en_passant_coordinate = self._handle_en_passant(action, piece)
204            return True, en_passant_coordinate
205
206        return False, None
207
208    def is_insufficient_material(self) -> bool:
209        # Meta actions cannot cause a game to become in an insufficient material state.
210        if (not isinstance(self.get_previous_action(), chessai.core.action.MoveAction)):
211            return False
212
213        for color in chessai.core.types.Color:
214            if (not self._is_insufficient_material(color)):
215                return False
216
217        return True
218
219    def _is_insufficient_material(self, color: chessai.core.types.Color) -> bool:
220        """
221        Checks if the given color has insufficient winning material.
222
223        This is guaranteed to return False if the color can still win the game.
224
225        The converse does not necessarily hold: only material is considered (including bishop square colors), not piece positions.
226        Fortress positions or forced lines may still return False even with no winning continuation.
227        """
228
229        own_pieces = {}
230        opponent_pieces = {}
231
232        own_knights = False
233        own_bishops: dict[chessai.core.coordinate.Coordinate, chessai.core.piece.Piece] = {}
234
235        for (file, rank_dict) in self.board.pieces.items():
236            for (rank, piece) in rank_dict.items():
237                coordinate = chessai.core.coordinate.Coordinate(file, rank)
238
239                if (piece.color == color):
240                    own_pieces[coordinate] = piece
241
242                    # Pawns, rooks, or queens are always sufficient material.
243                    if (isinstance(piece, (chessai.chess.piece.Pawn, chessai.chess.piece.Rook, chessai.chess.piece.Queen))):
244                        return False
245
246                    if (isinstance(piece, chessai.chess.piece.Knight)):
247                        own_knights = True
248                    elif (isinstance(piece, chessai.chess.piece.Bishop)):
249                        own_bishops[coordinate] = piece
250                else:
251                    opponent_pieces[coordinate] = piece
252
253        # Knights are only insufficient material if:
254        # (1) We do not have any other pieces, including more than one knight.
255        # (2) The opponent does not have pawns, knights, bishops or rooks.
256        #     These would allow selfmate.
257        if (own_knights):
258            # Sufficient material via case 1.
259            if (len(own_pieces) > 2):
260                return False
261
262            for piece in opponent_pieces.values():
263                if isinstance(piece, (chessai.chess.piece.Knight, chessai.chess.piece.Bishop, chessai.chess.piece.Rook, chessai.chess.piece.Pawn)):
264                    # Sufficient material via case 2.
265                    return False
266
267            return True
268
269        # Bishops are only insufficient material if:
270        # (1) We do not have any other pieces, including bishops of the opposite color.
271        # (2) The opponent does not have bishops of the opposite color, pawns or knights.
272        #     These would allow selfmate.
273        if (len(own_bishops) > 0):
274            square_parities = set()
275            for (coordinate, _) in own_bishops.items():
276                square_parity = (coordinate.file + coordinate.rank) % 2
277                square_parities.add(square_parity)
278
279            # Sufficient material via case 1.
280            if (len(square_parities) > 1):
281                return False
282
283            for piece in opponent_pieces.values():
284                # Sufficient material via case 2 (using pawns or knights).
285                if (isinstance(piece, (chessai.chess.piece.Pawn, chessai.chess.piece.Knight))):
286                    return False
287
288            opponent_bishop_parities = set()
289            for (coordinate, piece) in opponent_pieces.items():
290                if (isinstance(piece, chessai.chess.piece.Bishop)):
291                    opponent_parity = (coordinate.file + coordinate.rank) % 2
292                    opponent_bishop_parities.add(opponent_parity)
293
294            opponent_has_opposite_bishops = bool(opponent_bishop_parities - square_parities)
295
296            # Determine sufficient material via case 2 when the opponent has bishops of the opposite color.
297            return (not opponent_has_opposite_bishops)
298
299        # King alone.
300        return True
301
302    def _handle_castling(self, action: chessai.core.action.MoveAction, piece: chessai.core.piece.Piece) -> None:
303        # Build the rook action for castling.
304        rook_action: chessai.core.action.Action | None = None
305        back_rank = action.start_coordinate.rank
306
307        if (action.end_coordinate.file > action.start_coordinate.file):
308            # Kingside: rook moves from the h-file to just right of the king's destination.
309            rook_start = chessai.core.coordinate.Coordinate(self.board.num_files - 1, back_rank)
310            rook_end   = chessai.core.coordinate.Coordinate(action.end_coordinate.file - 1, back_rank)
311        else:
312            # Queenside: rook moves from the a-file to just left of the king's destination.
313            rook_start = chessai.core.coordinate.Coordinate(0, back_rank)
314            rook_end   = chessai.core.coordinate.Coordinate(action.end_coordinate.file + 1, back_rank)
315
316        rook_action = chessai.core.action.MoveAction(rook_start, rook_end)
317
318        # Move the rook for castling.
319        self.board.push(rook_action)
320
321    def _handle_en_passant(self,
322                           action: chessai.core.action.MoveAction,
323                           piece: chessai.core.piece.Piece,
324                           ) -> chessai.core.coordinate.Coordinate | None:
325        # The captured pawn is on the same rank as the moving pawn, on the destination file.
326        captured_piece_coordinate = chessai.core.coordinate.Coordinate(
327            action.end_coordinate.file,
328            action.start_coordinate.rank,
329        )
330
331        # Remove the en-passant captured pawn from its actual coordinate.
332        self.board.remove(captured_piece_coordinate)
333
334        # Return the en-passant target coordinate, based on the action.
335        return self._compute_en_passant(action, piece)
336
337    def _handle_promotion(self, action: chessai.core.action.PromotionAction, piece: chessai.core.piece.Piece) -> None:
338        # Handle promotion by replacing the pawn with the promoted piece.
339        self.board.set(action.promotion, action.end_coordinate)
340
341    def _get_castling_moves(self) -> list[chessai.core.action.MoveAction]:
342        """ Get all pseudo-legal castling moves for the current player. """
343
344        # Determine the back rank and castling rights for the current player.
345        if (self.turn == chessai.core.types.Color.WHITE):
346            back_rank           = 0
347            kingside_rights     = self.castling_rights.white_kingside
348            queenside_rights    = self.castling_rights.white_queenside
349        else:
350            back_rank           = self.board.num_ranks - 1
351            kingside_rights     = self.castling_rights.black_kingside
352            queenside_rights    = self.castling_rights.black_queenside
353
354        if ((not kingside_rights) and (not queenside_rights)):
355            return []
356
357        # Find the king.
358        king_coordinate = self.get_king_coordinate(self.turn)
359
360        # The king must be on the back rank to castle.
361        if ((king_coordinate is None) or (king_coordinate.rank != back_rank)):
362            return []
363
364        # Get the coordinates that the opponent can attack,
365        # as a castling move cannot pass through a square that is attacked.
366        attacked_coordinates = self.get_attacked_coordinates(self.turn.opposite())
367
368        # The king cannot castle if it is being attacked.
369        if (king_coordinate in attacked_coordinates):
370            return []
371
372        actions: list[chessai.core.action.MoveAction] = []
373
374        # Add Kingside castling.
375        if (kingside_rights):
376            rook_cordinate = chessai.core.coordinate.Coordinate((self.board.num_files - 1), back_rank)
377            rook_piece = self.get(rook_cordinate)
378
379            # Check that the files to the right of the king and left of the rook are empty.
380            all_safe = True
381            file = king_coordinate.file + 1
382            while (file < rook_cordinate.file):
383                empty_coord = chessai.core.coordinate.Coordinate(file, back_rank)
384
385                if (empty_coord in attacked_coordinates):
386                    all_safe = False
387                    break
388
389                if (self.board.has_piece(empty_coord)):
390                    all_safe = False
391                    break
392
393                file = file + 1
394
395            if ((rook_piece is not None)
396                    and (isinstance(rook_piece, chessai.chess.piece.Rook))
397                    and (rook_piece.color == self.turn)
398                    and (all_safe)):
399                king_dest = chessai.core.coordinate.Coordinate((self.board.num_files - 2), back_rank)
400                actions.append(chessai.core.action.MoveAction(king_coordinate, king_dest))
401
402        # Add Queenside castling.
403        if (queenside_rights):
404            rook_cordinate = chessai.core.coordinate.Coordinate(0, back_rank)
405            rook_piece = self.get(rook_cordinate)
406
407            # Check that the files to the right of the king and left of the rook are empty.
408            all_safe = True
409            file = king_coordinate.file - 1
410            while (file > 0):
411                empty_coord = chessai.core.coordinate.Coordinate(file, back_rank)
412
413                if (empty_coord in attacked_coordinates):
414                    all_safe = False
415                    break
416
417                if (self.board.has_piece(empty_coord)):
418                    all_safe = False
419                    break
420
421                file = file - 1
422
423            if ((rook_piece is not None)
424                    and (isinstance(rook_piece, chessai.chess.piece.Rook))
425                    and (rook_piece.color == self.turn)
426                    and (all_safe)):
427                king_dest = chessai.core.coordinate.Coordinate(2, back_rank)
428                actions.append(chessai.core.action.MoveAction(king_coordinate, king_dest))
429
430        return actions
431
432    def _get_pawn_double_pushes(self) -> list[chessai.core.action.MoveAction]:
433        """ Get all pseudo-legal pawn double push moves for the current player. """
434
435        if (self.turn == chessai.core.types.Color.WHITE):
436            start_rank = 1
437            direction  = 1
438        else:
439            start_rank = self.board.num_ranks - 2
440            direction  = -1
441
442        actions: list[chessai.core.action.MoveAction] = []
443
444        for (file, rank_dict) in self.board.pieces.items():
445            if (start_rank not in rank_dict):
446                continue
447
448            piece = rank_dict[start_rank]
449            if (piece.color != self.turn):
450                continue
451
452            if (not isinstance(piece, chessai.chess.piece.Pawn)):
453                continue
454
455            coordinate = chessai.core.coordinate.Coordinate(file, start_rank)
456
457            # Both the single and double push coordinates must be empty.
458            single_push_coord = coordinate.offset(0, direction)
459            if (self.board.has_piece(single_push_coord)):
460                continue
461
462            double_push_coord = coordinate.offset(0, direction * 2)
463            if (self.board.has_piece(double_push_coord)):
464                continue
465
466            actions.append(chessai.core.action.MoveAction(coordinate, double_push_coord))
467
468        return actions
469
470    def _get_en_passant_captures(self) -> list[chessai.core.action.MoveAction]:
471        """ Get all pseudo-legal en-passant captures for the current player. """
472
473        actions: list[chessai.core.action.MoveAction] = []
474
475        if (self.en_passant_coordinate is None):
476            return actions
477
478        if (self.turn == chessai.core.types.Color.WHITE):
479            # The en-passant coordinate encodes the coordinate that must be taken.
480            # So, the pawn that can attack it will be one rank below for white.
481            capturing_rank = self.en_passant_coordinate.rank - 1
482        else:
483            capturing_rank = self.en_passant_coordinate.rank + 1
484
485        # The pawn will have to be on an adjacent file.
486        for file_delta in [-1, 1]:
487            candidate_capture_coord = chessai.core.coordinate.Coordinate(
488                self.en_passant_coordinate.file + file_delta,
489                capturing_rank,
490            )
491
492            if (not self.board.is_within_bounds(candidate_capture_coord.file, candidate_capture_coord.rank)):
493                continue
494
495            piece = self.get(candidate_capture_coord)
496            if (piece is None):
497                continue
498
499            if (not isinstance(piece, chessai.chess.piece.Pawn)):
500                continue
501
502            if (piece.color != self.turn):
503                continue
504
505            actions.append(chessai.core.action.MoveAction(candidate_capture_coord, self.en_passant_coordinate))
506
507        return actions
508
509    def _get_promotion_actions(self,
510            start_coordinate: chessai.core.coordinate.Coordinate,
511            end_coordinate: chessai.core.coordinate.Coordinate) -> list[chessai.core.action.MoveAction]:
512        """
513        Expand a pawn move that reaches the back rank into one action per promotable piece type.
514
515        Called when a pawn reaches the back rank of the opposing side.
516        Always returns exactly four actions (queen, rook, bishop, knight).
517        """
518
519        return [
520            chessai.core.action.PromotionAction(start_coordinate, end_coordinate, chessai.chess.piece.Queen(self.turn)),
521            chessai.core.action.PromotionAction(start_coordinate, end_coordinate, chessai.chess.piece.Rook(self.turn)),
522            chessai.core.action.PromotionAction(start_coordinate, end_coordinate, chessai.chess.piece.Bishop(self.turn)),
523            chessai.core.action.PromotionAction(start_coordinate, end_coordinate, chessai.chess.piece.Knight(self.turn)),
524        ]
525
526    def _update_castling_rights(self,
527            action: chessai.core.action.MoveAction,
528            piece: chessai.core.piece.Piece) -> None:
529        """ Revoke castling rights that are no longer valid after the given move. """
530
531        white_kingside_rook  = chessai.core.coordinate.Coordinate(self.board.num_files - 1, 0)
532        white_queenside_rook = chessai.core.coordinate.Coordinate(0, 0)
533        black_kingside_rook  = chessai.core.coordinate.Coordinate(self.board.num_files - 1, self.board.num_ranks - 1)
534        black_queenside_rook = chessai.core.coordinate.Coordinate(0, self.board.num_ranks - 1)
535
536        # King moves, which forfeits both rights for that color.
537        if (isinstance(piece, chessai.chess.piece.King)):
538            if (piece.color == chessai.core.types.Color.WHITE):
539                self.castling_rights = self.castling_rights.revoke_rights(chessai.core.castling.ALL_WHITE_RIGHTS)
540            else:
541                self.castling_rights = self.castling_rights.revoke_rights(chessai.core.castling.ALL_BLACK_RIGHTS)
542
543        # Rook moves from its starting coordinate.
544        if (action.start_coordinate == white_kingside_rook):
545            self.castling_rights = self.castling_rights.revoke_rights(chessai.core.castling.WHITE_KINGSIDE_RIGHTS)
546
547        if (action.start_coordinate == white_queenside_rook):
548            self.castling_rights = self.castling_rights.revoke_rights(chessai.core.castling.WHITE_QUEENSIDE_RIGHTS)
549
550        if (action.start_coordinate == black_kingside_rook):
551            self.castling_rights = self.castling_rights.revoke_rights(chessai.core.castling.BLACK_KINGSIDE_RIGHTS)
552
553        if (action.start_coordinate == black_queenside_rook):
554            self.castling_rights = self.castling_rights.revoke_rights(chessai.core.castling.BLACK_QUEENSIDE_RIGHTS)
555
556        # Rook captured on its starting coordinate.
557        if (action.end_coordinate == white_kingside_rook):
558            self.castling_rights = self.castling_rights.revoke_rights(chessai.core.castling.WHITE_KINGSIDE_RIGHTS)
559
560        if (action.end_coordinate == white_queenside_rook):
561            self.castling_rights = self.castling_rights.revoke_rights(chessai.core.castling.WHITE_QUEENSIDE_RIGHTS)
562
563        if (action.end_coordinate == black_kingside_rook):
564            self.castling_rights = self.castling_rights.revoke_rights(chessai.core.castling.BLACK_KINGSIDE_RIGHTS)
565
566        if (action.end_coordinate == black_queenside_rook):
567            self.castling_rights = self.castling_rights.revoke_rights(chessai.core.castling.BLACK_QUEENSIDE_RIGHTS)
568
569    def _compute_en_passant(self,
570            action: chessai.core.action.MoveAction,
571            piece: chessai.core.piece.Piece) -> chessai.core.coordinate.Coordinate | None:
572        """
573        Return the new en-passant target coordinate after this move, or None.
574
575        A target coordinate is set only when a pawn advances two ranks.
576        The target is the coordinate the pawn passed through (i.e. one rank behind the destination), which is standard FEN notation.
577        """
578
579        if (not isinstance(piece, chessai.chess.piece.Pawn)):
580            return None
581
582        if (action.start_coordinate.rank_distance(action.end_coordinate) != 2):
583            return None
584
585        # The en-passant target is the coordinate the pawn skipped over.
586        passed_rank = (action.start_coordinate.rank + action.end_coordinate.rank) // 2
587        return chessai.core.coordinate.Coordinate(action.start_coordinate.file, passed_rank)
588
589    def copy(self,
590            context: typing.Union[edq.util.serial.SerializationContext, None] = None,
591            ) -> 'GameState':
592        new_state = super().copy()
593        new_state = typing.cast(GameState, new_state)
594
595        return new_state
596
597def base_eval(
598        state: chessai.core.gamestate.GameState,
599        action: chessai.core.action.Action | None = None,
600        agent: typing.Any | None = None,
601        **kwargs: typing.Any) -> float:
602    """
603    The most basic evaluation function, which just uses the difference in piece value on the board.
604    """
605
606    piece_values: dict[type[chessai.core.piece.Piece], int] = {
607        chessai.chess.piece.Pawn: 1,
608        chessai.chess.piece.Knight: 3,
609        chessai.chess.piece.Bishop: 3,
610        chessai.chess.piece.Rook: 5,
611        chessai.chess.piece.Queen: 9,
612        chessai.chess.piece.King: 9999,
613    }
614
615    # The difference in pieces from white's perspective.
616    board_value = 0
617    for piece in state.board.all_pieces():
618        piece_value = piece_values.get(type(piece), 0)
619        if (piece.color == chessai.core.types.Color.WHITE):
620            board_value += piece_value
621        else:
622            board_value -= piece_value
623
624    if (state.turn == chessai.core.types.Color.WHITE):
625        return board_value
626
627    # The piece difference is the opposite for black.
628    return -1 * board_value
class GameState(chessai.core.gamestate.GameState):
 10class GameState(chessai.core.gamestate.GameState):
 11    """ A game state specific to a standard Pacman game. """
 12
 13    def game_complete(self) -> tuple[list[chessai.core.types.Color], float]:
 14        winners = self.get_winners()
 15
 16        # Score is based on white's perspective using standard chess scoring.
 17        if (chessai.core.types.Color.WHITE in winners):
 18            score = 1.0
 19        elif (chessai.core.types.Color.BLACK in winners):
 20            score = 0.0
 21        else:
 22            score = 0.5
 23
 24        return (winners, score)
 25
 26    def is_check(self, color: chessai.core.types.Color, started_in_check: bool = True) -> bool:
 27        # Find the king of the given color.
 28        king_coordinate = self.get_king_coordinate(color)
 29
 30        # If there is no King, they cannot be in check.
 31        # This should never happen in standard chess games with valid positions.
 32        if (king_coordinate is None):
 33            return False
 34
 35        # Get the most recent action to try to avoid work when not doing a full check.
 36        previous_action = self.get_previous_action()
 37
 38        if ((not started_in_check) and isinstance(previous_action, chessai.core.action.MoveAction)):
 39            check_potential = self._has_check_potential(king_coordinate, previous_action)
 40        else:
 41            check_potential = True
 42
 43        # Skip checking moves that could not have opened us to check.
 44        if (not check_potential):
 45            return False
 46
 47        # Check if any opponent piece can attack the king's coordinate.
 48        old_turn = self.turn
 49        self.turn = color.opposite()
 50
 51        try:
 52            opponent_actions = self._get_pseudo_legal_moves()
 53            for action in opponent_actions:
 54                if (action.end_coordinate == king_coordinate):
 55                    return True
 56
 57            return False
 58        finally:
 59            self.turn = old_turn
 60
 61    def get_king_coordinate(self, color: chessai.core.types.Color) -> chessai.core.coordinate.Coordinate | None:
 62        """ Get the coordinate of the king for the given color. """
 63
 64        for (file, rank_dict) in self.board.pieces.items():
 65            for (rank, piece) in rank_dict.items():
 66                if ((piece.color == color) and (isinstance(piece, chessai.chess.piece.King))):
 67                    return chessai.core.coordinate.Coordinate(file, rank)
 68
 69        # If there's no king, the position is invalid, which should never happen.
 70        return None
 71
 72    def _has_check_potential(self, king_coordinate: chessai.core.coordinate.Coordinate, previous_action: chessai.core.action.MoveAction) -> bool:
 73        """
 74        Returns whether the player who played the most recent move could be in check.
 75
 76        Assumes the player was not in check before playing their most recent move.
 77        """
 78
 79        # Check if the king just moved, which means it could have moved into check.
 80        if (previous_action.end_coordinate == king_coordinate):
 81            return True
 82
 83        # Check if a piece moved on the king's file, rank, or diagonal.
 84        # This move would allow for discovery checks.
 85        if ((previous_action.start_coordinate.file == king_coordinate.file) or (previous_action.start_coordinate.rank == king_coordinate.rank)):
 86            # A piece moved on the king's rank or file, so the king could be under attack from a rook or queen.
 87            return True
 88
 89        # Check the diagonal.
 90        file_delta = abs(king_coordinate.file - previous_action.start_coordinate.file)
 91        rank_delta = abs(king_coordinate.rank - previous_action.start_coordinate.rank)
 92
 93        # If a piece moved away from the king's diagonal, the king could be under attack from a bishop or queen.
 94        return file_delta == rank_delta
 95
 96    def _should_reset_halfmove_clock(self, action: chessai.core.action.Action, piece: chessai.core.piece.Piece) -> bool:
 97        return (isinstance(piece, chessai.chess.piece.Pawn))
 98
 99    def _get_pseudo_legal_moves(self) -> list[chessai.core.action.MoveAction]:
100        # Get the base movement from all pieces.
101        actions = self._get_base_move_actions()
102
103        # Add any castling moves.
104        actions.extend(self._get_castling_moves())
105
106        # Add any pawn double pushes.
107        actions.extend(self._get_pawn_double_pushes())
108
109        # Add any en-passant captures.
110        actions.extend(self._get_en_passant_captures())
111
112        return actions
113
114    def _get_base_move_actions(self) -> list[chessai.core.action.MoveAction]:
115        """
116        Expands all movement vectors from the pieces on the board.
117        Pieces will move until they can capture or reach the end of the board.
118
119        Detects when pawns reach the end of the board and can be promoted.
120        """
121
122        actions: list[chessai.core.action.MoveAction] = []
123
124        # Determine the rank for pawn promotions.
125        if (self.turn == chessai.core.types.Color.WHITE):
126            promotion_rank = self.board.num_ranks - 1
127        else:
128            promotion_rank = 0
129
130        for (file, rank_dict) in self.board.pieces.items():
131            for (rank, piece) in rank_dict.items():
132                if (piece.color != self.turn):
133                    continue
134
135                for movement_vector in piece.move_vectors():
136                    current_rank = rank
137                    current_file = file
138
139                    coordinate = chessai.core.coordinate.Coordinate(file, rank)
140
141                    num_repetitions = movement_vector.num_repetitions
142                    while (num_repetitions != 0):
143                        current_file += movement_vector.file_delta
144                        current_rank += movement_vector.rank_delta
145                        if (not self.board.is_within_bounds(current_file, current_rank)):
146                            break
147
148                        occupant = self.board.get(current_file, current_rank)
149
150                        is_occupied = occupant is not None
151                        is_enemy    = (occupant is not None) and (occupant.color != piece.color)
152                        is_ally     = (occupant is not None) and (occupant.color == piece.color)
153
154                        # No movement type can move on top of an ally.
155                        if (is_ally):
156                            break
157
158                        # Push movement types cannot capture.
159                        if ((movement_vector.kind == chessai.core.piece.MoveKind.PUSH) and is_occupied):
160                            break
161
162                        # Capture movement types must target an enemy.
163                        if ((movement_vector.kind == chessai.core.piece.MoveKind.CAPTURE) and (not is_enemy)):
164                            break
165
166                        current_coordinate = chessai.core.coordinate.Coordinate(current_file, current_rank)
167
168                        # Check if this action would lead to a pawn promotion.
169                        if ((isinstance(piece, chessai.chess.piece.Pawn)) and (current_coordinate.rank == promotion_rank)):
170                            actions.extend(self._get_promotion_actions(coordinate, current_coordinate))
171                        else:
172                            actions.append(chessai.core.action.MoveAction(coordinate, current_coordinate))
173
174                        if (is_occupied):
175                            break
176
177                        num_repetitions -= 1
178
179        return actions
180
181    def _process_special_move(self,
182                              action: chessai.core.action.Action,
183                              piece: chessai.core.piece.Piece) -> tuple[bool, chessai.core.coordinate.Coordinate | None]:
184        if (not isinstance(action, chessai.core.action.MoveAction)):
185            return False, None
186
187        # Update castling rights.
188        self._update_castling_rights(action, piece)
189
190        # Handle promoting pieces.
191        if isinstance(action, chessai.core.action.PromotionAction):
192            self._handle_promotion(action, piece)
193
194        # Detect castling by seeing if the king moved more than two files.
195        if ((isinstance(piece, chessai.chess.piece.King))
196                and (action.start_coordinate.file_distance(action.end_coordinate) > 1)):
197            self._handle_castling(action, piece)
198            return False, None
199
200        # Detect en-passant by checking if a pawn moves diagonally to an empty coordinate.
201        if ((isinstance(piece, chessai.chess.piece.Pawn))
202                and (action.start_coordinate.file_distance(action.end_coordinate) == 1)
203                and (self.get(action.end_coordinate) is None)):
204            en_passant_coordinate = self._handle_en_passant(action, piece)
205            return True, en_passant_coordinate
206
207        return False, None
208
209    def is_insufficient_material(self) -> bool:
210        # Meta actions cannot cause a game to become in an insufficient material state.
211        if (not isinstance(self.get_previous_action(), chessai.core.action.MoveAction)):
212            return False
213
214        for color in chessai.core.types.Color:
215            if (not self._is_insufficient_material(color)):
216                return False
217
218        return True
219
220    def _is_insufficient_material(self, color: chessai.core.types.Color) -> bool:
221        """
222        Checks if the given color has insufficient winning material.
223
224        This is guaranteed to return False if the color can still win the game.
225
226        The converse does not necessarily hold: only material is considered (including bishop square colors), not piece positions.
227        Fortress positions or forced lines may still return False even with no winning continuation.
228        """
229
230        own_pieces = {}
231        opponent_pieces = {}
232
233        own_knights = False
234        own_bishops: dict[chessai.core.coordinate.Coordinate, chessai.core.piece.Piece] = {}
235
236        for (file, rank_dict) in self.board.pieces.items():
237            for (rank, piece) in rank_dict.items():
238                coordinate = chessai.core.coordinate.Coordinate(file, rank)
239
240                if (piece.color == color):
241                    own_pieces[coordinate] = piece
242
243                    # Pawns, rooks, or queens are always sufficient material.
244                    if (isinstance(piece, (chessai.chess.piece.Pawn, chessai.chess.piece.Rook, chessai.chess.piece.Queen))):
245                        return False
246
247                    if (isinstance(piece, chessai.chess.piece.Knight)):
248                        own_knights = True
249                    elif (isinstance(piece, chessai.chess.piece.Bishop)):
250                        own_bishops[coordinate] = piece
251                else:
252                    opponent_pieces[coordinate] = piece
253
254        # Knights are only insufficient material if:
255        # (1) We do not have any other pieces, including more than one knight.
256        # (2) The opponent does not have pawns, knights, bishops or rooks.
257        #     These would allow selfmate.
258        if (own_knights):
259            # Sufficient material via case 1.
260            if (len(own_pieces) > 2):
261                return False
262
263            for piece in opponent_pieces.values():
264                if isinstance(piece, (chessai.chess.piece.Knight, chessai.chess.piece.Bishop, chessai.chess.piece.Rook, chessai.chess.piece.Pawn)):
265                    # Sufficient material via case 2.
266                    return False
267
268            return True
269
270        # Bishops are only insufficient material if:
271        # (1) We do not have any other pieces, including bishops of the opposite color.
272        # (2) The opponent does not have bishops of the opposite color, pawns or knights.
273        #     These would allow selfmate.
274        if (len(own_bishops) > 0):
275            square_parities = set()
276            for (coordinate, _) in own_bishops.items():
277                square_parity = (coordinate.file + coordinate.rank) % 2
278                square_parities.add(square_parity)
279
280            # Sufficient material via case 1.
281            if (len(square_parities) > 1):
282                return False
283
284            for piece in opponent_pieces.values():
285                # Sufficient material via case 2 (using pawns or knights).
286                if (isinstance(piece, (chessai.chess.piece.Pawn, chessai.chess.piece.Knight))):
287                    return False
288
289            opponent_bishop_parities = set()
290            for (coordinate, piece) in opponent_pieces.items():
291                if (isinstance(piece, chessai.chess.piece.Bishop)):
292                    opponent_parity = (coordinate.file + coordinate.rank) % 2
293                    opponent_bishop_parities.add(opponent_parity)
294
295            opponent_has_opposite_bishops = bool(opponent_bishop_parities - square_parities)
296
297            # Determine sufficient material via case 2 when the opponent has bishops of the opposite color.
298            return (not opponent_has_opposite_bishops)
299
300        # King alone.
301        return True
302
303    def _handle_castling(self, action: chessai.core.action.MoveAction, piece: chessai.core.piece.Piece) -> None:
304        # Build the rook action for castling.
305        rook_action: chessai.core.action.Action | None = None
306        back_rank = action.start_coordinate.rank
307
308        if (action.end_coordinate.file > action.start_coordinate.file):
309            # Kingside: rook moves from the h-file to just right of the king's destination.
310            rook_start = chessai.core.coordinate.Coordinate(self.board.num_files - 1, back_rank)
311            rook_end   = chessai.core.coordinate.Coordinate(action.end_coordinate.file - 1, back_rank)
312        else:
313            # Queenside: rook moves from the a-file to just left of the king's destination.
314            rook_start = chessai.core.coordinate.Coordinate(0, back_rank)
315            rook_end   = chessai.core.coordinate.Coordinate(action.end_coordinate.file + 1, back_rank)
316
317        rook_action = chessai.core.action.MoveAction(rook_start, rook_end)
318
319        # Move the rook for castling.
320        self.board.push(rook_action)
321
322    def _handle_en_passant(self,
323                           action: chessai.core.action.MoveAction,
324                           piece: chessai.core.piece.Piece,
325                           ) -> chessai.core.coordinate.Coordinate | None:
326        # The captured pawn is on the same rank as the moving pawn, on the destination file.
327        captured_piece_coordinate = chessai.core.coordinate.Coordinate(
328            action.end_coordinate.file,
329            action.start_coordinate.rank,
330        )
331
332        # Remove the en-passant captured pawn from its actual coordinate.
333        self.board.remove(captured_piece_coordinate)
334
335        # Return the en-passant target coordinate, based on the action.
336        return self._compute_en_passant(action, piece)
337
338    def _handle_promotion(self, action: chessai.core.action.PromotionAction, piece: chessai.core.piece.Piece) -> None:
339        # Handle promotion by replacing the pawn with the promoted piece.
340        self.board.set(action.promotion, action.end_coordinate)
341
342    def _get_castling_moves(self) -> list[chessai.core.action.MoveAction]:
343        """ Get all pseudo-legal castling moves for the current player. """
344
345        # Determine the back rank and castling rights for the current player.
346        if (self.turn == chessai.core.types.Color.WHITE):
347            back_rank           = 0
348            kingside_rights     = self.castling_rights.white_kingside
349            queenside_rights    = self.castling_rights.white_queenside
350        else:
351            back_rank           = self.board.num_ranks - 1
352            kingside_rights     = self.castling_rights.black_kingside
353            queenside_rights    = self.castling_rights.black_queenside
354
355        if ((not kingside_rights) and (not queenside_rights)):
356            return []
357
358        # Find the king.
359        king_coordinate = self.get_king_coordinate(self.turn)
360
361        # The king must be on the back rank to castle.
362        if ((king_coordinate is None) or (king_coordinate.rank != back_rank)):
363            return []
364
365        # Get the coordinates that the opponent can attack,
366        # as a castling move cannot pass through a square that is attacked.
367        attacked_coordinates = self.get_attacked_coordinates(self.turn.opposite())
368
369        # The king cannot castle if it is being attacked.
370        if (king_coordinate in attacked_coordinates):
371            return []
372
373        actions: list[chessai.core.action.MoveAction] = []
374
375        # Add Kingside castling.
376        if (kingside_rights):
377            rook_cordinate = chessai.core.coordinate.Coordinate((self.board.num_files - 1), back_rank)
378            rook_piece = self.get(rook_cordinate)
379
380            # Check that the files to the right of the king and left of the rook are empty.
381            all_safe = True
382            file = king_coordinate.file + 1
383            while (file < rook_cordinate.file):
384                empty_coord = chessai.core.coordinate.Coordinate(file, back_rank)
385
386                if (empty_coord in attacked_coordinates):
387                    all_safe = False
388                    break
389
390                if (self.board.has_piece(empty_coord)):
391                    all_safe = False
392                    break
393
394                file = file + 1
395
396            if ((rook_piece is not None)
397                    and (isinstance(rook_piece, chessai.chess.piece.Rook))
398                    and (rook_piece.color == self.turn)
399                    and (all_safe)):
400                king_dest = chessai.core.coordinate.Coordinate((self.board.num_files - 2), back_rank)
401                actions.append(chessai.core.action.MoveAction(king_coordinate, king_dest))
402
403        # Add Queenside castling.
404        if (queenside_rights):
405            rook_cordinate = chessai.core.coordinate.Coordinate(0, back_rank)
406            rook_piece = self.get(rook_cordinate)
407
408            # Check that the files to the right of the king and left of the rook are empty.
409            all_safe = True
410            file = king_coordinate.file - 1
411            while (file > 0):
412                empty_coord = chessai.core.coordinate.Coordinate(file, back_rank)
413
414                if (empty_coord in attacked_coordinates):
415                    all_safe = False
416                    break
417
418                if (self.board.has_piece(empty_coord)):
419                    all_safe = False
420                    break
421
422                file = file - 1
423
424            if ((rook_piece is not None)
425                    and (isinstance(rook_piece, chessai.chess.piece.Rook))
426                    and (rook_piece.color == self.turn)
427                    and (all_safe)):
428                king_dest = chessai.core.coordinate.Coordinate(2, back_rank)
429                actions.append(chessai.core.action.MoveAction(king_coordinate, king_dest))
430
431        return actions
432
433    def _get_pawn_double_pushes(self) -> list[chessai.core.action.MoveAction]:
434        """ Get all pseudo-legal pawn double push moves for the current player. """
435
436        if (self.turn == chessai.core.types.Color.WHITE):
437            start_rank = 1
438            direction  = 1
439        else:
440            start_rank = self.board.num_ranks - 2
441            direction  = -1
442
443        actions: list[chessai.core.action.MoveAction] = []
444
445        for (file, rank_dict) in self.board.pieces.items():
446            if (start_rank not in rank_dict):
447                continue
448
449            piece = rank_dict[start_rank]
450            if (piece.color != self.turn):
451                continue
452
453            if (not isinstance(piece, chessai.chess.piece.Pawn)):
454                continue
455
456            coordinate = chessai.core.coordinate.Coordinate(file, start_rank)
457
458            # Both the single and double push coordinates must be empty.
459            single_push_coord = coordinate.offset(0, direction)
460            if (self.board.has_piece(single_push_coord)):
461                continue
462
463            double_push_coord = coordinate.offset(0, direction * 2)
464            if (self.board.has_piece(double_push_coord)):
465                continue
466
467            actions.append(chessai.core.action.MoveAction(coordinate, double_push_coord))
468
469        return actions
470
471    def _get_en_passant_captures(self) -> list[chessai.core.action.MoveAction]:
472        """ Get all pseudo-legal en-passant captures for the current player. """
473
474        actions: list[chessai.core.action.MoveAction] = []
475
476        if (self.en_passant_coordinate is None):
477            return actions
478
479        if (self.turn == chessai.core.types.Color.WHITE):
480            # The en-passant coordinate encodes the coordinate that must be taken.
481            # So, the pawn that can attack it will be one rank below for white.
482            capturing_rank = self.en_passant_coordinate.rank - 1
483        else:
484            capturing_rank = self.en_passant_coordinate.rank + 1
485
486        # The pawn will have to be on an adjacent file.
487        for file_delta in [-1, 1]:
488            candidate_capture_coord = chessai.core.coordinate.Coordinate(
489                self.en_passant_coordinate.file + file_delta,
490                capturing_rank,
491            )
492
493            if (not self.board.is_within_bounds(candidate_capture_coord.file, candidate_capture_coord.rank)):
494                continue
495
496            piece = self.get(candidate_capture_coord)
497            if (piece is None):
498                continue
499
500            if (not isinstance(piece, chessai.chess.piece.Pawn)):
501                continue
502
503            if (piece.color != self.turn):
504                continue
505
506            actions.append(chessai.core.action.MoveAction(candidate_capture_coord, self.en_passant_coordinate))
507
508        return actions
509
510    def _get_promotion_actions(self,
511            start_coordinate: chessai.core.coordinate.Coordinate,
512            end_coordinate: chessai.core.coordinate.Coordinate) -> list[chessai.core.action.MoveAction]:
513        """
514        Expand a pawn move that reaches the back rank into one action per promotable piece type.
515
516        Called when a pawn reaches the back rank of the opposing side.
517        Always returns exactly four actions (queen, rook, bishop, knight).
518        """
519
520        return [
521            chessai.core.action.PromotionAction(start_coordinate, end_coordinate, chessai.chess.piece.Queen(self.turn)),
522            chessai.core.action.PromotionAction(start_coordinate, end_coordinate, chessai.chess.piece.Rook(self.turn)),
523            chessai.core.action.PromotionAction(start_coordinate, end_coordinate, chessai.chess.piece.Bishop(self.turn)),
524            chessai.core.action.PromotionAction(start_coordinate, end_coordinate, chessai.chess.piece.Knight(self.turn)),
525        ]
526
527    def _update_castling_rights(self,
528            action: chessai.core.action.MoveAction,
529            piece: chessai.core.piece.Piece) -> None:
530        """ Revoke castling rights that are no longer valid after the given move. """
531
532        white_kingside_rook  = chessai.core.coordinate.Coordinate(self.board.num_files - 1, 0)
533        white_queenside_rook = chessai.core.coordinate.Coordinate(0, 0)
534        black_kingside_rook  = chessai.core.coordinate.Coordinate(self.board.num_files - 1, self.board.num_ranks - 1)
535        black_queenside_rook = chessai.core.coordinate.Coordinate(0, self.board.num_ranks - 1)
536
537        # King moves, which forfeits both rights for that color.
538        if (isinstance(piece, chessai.chess.piece.King)):
539            if (piece.color == chessai.core.types.Color.WHITE):
540                self.castling_rights = self.castling_rights.revoke_rights(chessai.core.castling.ALL_WHITE_RIGHTS)
541            else:
542                self.castling_rights = self.castling_rights.revoke_rights(chessai.core.castling.ALL_BLACK_RIGHTS)
543
544        # Rook moves from its starting coordinate.
545        if (action.start_coordinate == white_kingside_rook):
546            self.castling_rights = self.castling_rights.revoke_rights(chessai.core.castling.WHITE_KINGSIDE_RIGHTS)
547
548        if (action.start_coordinate == white_queenside_rook):
549            self.castling_rights = self.castling_rights.revoke_rights(chessai.core.castling.WHITE_QUEENSIDE_RIGHTS)
550
551        if (action.start_coordinate == black_kingside_rook):
552            self.castling_rights = self.castling_rights.revoke_rights(chessai.core.castling.BLACK_KINGSIDE_RIGHTS)
553
554        if (action.start_coordinate == black_queenside_rook):
555            self.castling_rights = self.castling_rights.revoke_rights(chessai.core.castling.BLACK_QUEENSIDE_RIGHTS)
556
557        # Rook captured on its starting coordinate.
558        if (action.end_coordinate == white_kingside_rook):
559            self.castling_rights = self.castling_rights.revoke_rights(chessai.core.castling.WHITE_KINGSIDE_RIGHTS)
560
561        if (action.end_coordinate == white_queenside_rook):
562            self.castling_rights = self.castling_rights.revoke_rights(chessai.core.castling.WHITE_QUEENSIDE_RIGHTS)
563
564        if (action.end_coordinate == black_kingside_rook):
565            self.castling_rights = self.castling_rights.revoke_rights(chessai.core.castling.BLACK_KINGSIDE_RIGHTS)
566
567        if (action.end_coordinate == black_queenside_rook):
568            self.castling_rights = self.castling_rights.revoke_rights(chessai.core.castling.BLACK_QUEENSIDE_RIGHTS)
569
570    def _compute_en_passant(self,
571            action: chessai.core.action.MoveAction,
572            piece: chessai.core.piece.Piece) -> chessai.core.coordinate.Coordinate | None:
573        """
574        Return the new en-passant target coordinate after this move, or None.
575
576        A target coordinate is set only when a pawn advances two ranks.
577        The target is the coordinate the pawn passed through (i.e. one rank behind the destination), which is standard FEN notation.
578        """
579
580        if (not isinstance(piece, chessai.chess.piece.Pawn)):
581            return None
582
583        if (action.start_coordinate.rank_distance(action.end_coordinate) != 2):
584            return None
585
586        # The en-passant target is the coordinate the pawn skipped over.
587        passed_rank = (action.start_coordinate.rank + action.end_coordinate.rank) // 2
588        return chessai.core.coordinate.Coordinate(action.start_coordinate.file, passed_rank)
589
590    def copy(self,
591            context: typing.Union[edq.util.serial.SerializationContext, None] = None,
592            ) -> 'GameState':
593        new_state = super().copy()
594        new_state = typing.cast(GameState, new_state)
595
596        return new_state

A game state specific to a standard Pacman game.

def game_complete(self) -> tuple[list[chessai.core.types.Color], float]:
13    def game_complete(self) -> tuple[list[chessai.core.types.Color], float]:
14        winners = self.get_winners()
15
16        # Score is based on white's perspective using standard chess scoring.
17        if (chessai.core.types.Color.WHITE in winners):
18            score = 1.0
19        elif (chessai.core.types.Color.BLACK in winners):
20            score = 0.0
21        else:
22            score = 0.5
23
24        return (winners, score)

Indicate that the game has ended. The state should take any final actions and return the player colors of the winning agents (if any) and the score of the game.

def is_check( self, color: chessai.core.types.Color, started_in_check: bool = True) -> bool:
26    def is_check(self, color: chessai.core.types.Color, started_in_check: bool = True) -> bool:
27        # Find the king of the given color.
28        king_coordinate = self.get_king_coordinate(color)
29
30        # If there is no King, they cannot be in check.
31        # This should never happen in standard chess games with valid positions.
32        if (king_coordinate is None):
33            return False
34
35        # Get the most recent action to try to avoid work when not doing a full check.
36        previous_action = self.get_previous_action()
37
38        if ((not started_in_check) and isinstance(previous_action, chessai.core.action.MoveAction)):
39            check_potential = self._has_check_potential(king_coordinate, previous_action)
40        else:
41            check_potential = True
42
43        # Skip checking moves that could not have opened us to check.
44        if (not check_potential):
45            return False
46
47        # Check if any opponent piece can attack the king's coordinate.
48        old_turn = self.turn
49        self.turn = color.opposite()
50
51        try:
52            opponent_actions = self._get_pseudo_legal_moves()
53            for action in opponent_actions:
54                if (action.end_coordinate == king_coordinate):
55                    return True
56
57            return False
58        finally:
59            self.turn = old_turn

Determines if the given color, not the state's color, is in check.

def get_king_coordinate( self, color: chessai.core.types.Color) -> chessai.core.coordinate.Coordinate | None:
61    def get_king_coordinate(self, color: chessai.core.types.Color) -> chessai.core.coordinate.Coordinate | None:
62        """ Get the coordinate of the king for the given color. """
63
64        for (file, rank_dict) in self.board.pieces.items():
65            for (rank, piece) in rank_dict.items():
66                if ((piece.color == color) and (isinstance(piece, chessai.chess.piece.King))):
67                    return chessai.core.coordinate.Coordinate(file, rank)
68
69        # If there's no king, the position is invalid, which should never happen.
70        return None

Get the coordinate of the king for the given color.

def is_insufficient_material(self) -> bool:
209    def is_insufficient_material(self) -> bool:
210        # Meta actions cannot cause a game to become in an insufficient material state.
211        if (not isinstance(self.get_previous_action(), chessai.core.action.MoveAction)):
212            return False
213
214        for color in chessai.core.types.Color:
215            if (not self._is_insufficient_material(color)):
216                return False
217
218        return True

Processes if there is insufficient material to get a checkmate for all colors.

def copy( self, context: Optional[edq.util.common.SerializationContext] = None) -> GameState:
590    def copy(self,
591            context: typing.Union[edq.util.serial.SerializationContext, None] = None,
592            ) -> 'GameState':
593        new_state = super().copy()
594        new_state = typing.cast(GameState, new_state)
595
596        return new_state

Get a deep copy of this state.

Child classes are responsible for making any deep copies they need to.

def base_eval( state: chessai.core.gamestate.GameState, action: chessai.core.action.Action | None = None, agent: typing.Any | None = None, **kwargs: Any) -> float:
598def base_eval(
599        state: chessai.core.gamestate.GameState,
600        action: chessai.core.action.Action | None = None,
601        agent: typing.Any | None = None,
602        **kwargs: typing.Any) -> float:
603    """
604    The most basic evaluation function, which just uses the difference in piece value on the board.
605    """
606
607    piece_values: dict[type[chessai.core.piece.Piece], int] = {
608        chessai.chess.piece.Pawn: 1,
609        chessai.chess.piece.Knight: 3,
610        chessai.chess.piece.Bishop: 3,
611        chessai.chess.piece.Rook: 5,
612        chessai.chess.piece.Queen: 9,
613        chessai.chess.piece.King: 9999,
614    }
615
616    # The difference in pieces from white's perspective.
617    board_value = 0
618    for piece in state.board.all_pieces():
619        piece_value = piece_values.get(type(piece), 0)
620        if (piece.color == chessai.core.types.Color.WHITE):
621            board_value += piece_value
622        else:
623            board_value -= piece_value
624
625    if (state.turn == chessai.core.types.Color.WHITE):
626        return board_value
627
628    # The piece difference is the opposite for black.
629    return -1 * board_value

The most basic evaluation function, which just uses the difference in piece value on the board.