diff --git a/pyproject.toml b/pyproject.toml index a9e016b..eb4e43a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [build-system] -requires = ["setuptools >= 64"] -build-backend = "setuptools.build_meta" +requires = ["hatchling >= 1.20.0"] +build-backend = "hatchling.build" [project] name = "checkers" @@ -10,13 +10,11 @@ authors = [ ] description = "Graphical Checkers Game with AI support" readme = {file = "README.md", content-type = "text/markdown"} -license = {file = "LICENSE"} +license = "GPL-3.0" requires-python = ">=3.10" classifiers = [ "Development Status :: 4 - Beta", "Intended Audience :: End Users/Desktop", - "License :: OSI Approved :: GNU General Public License v3 (GPLv3)", - "License :: OSI Approved :: GNU Lesser General Public License v3 (LGPLv3)", "Natural Language :: English", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.10", @@ -46,8 +44,8 @@ dependencies = [ "exceptiongroup; python_version < '3.11'", ] -[tool.setuptools.dynamic] -version = {attr = "checkers.game.__version__"} +[tool.hatch.version] +path = "src/checkers/game.py" [project.urls] "Source" = "https://github.com/CoolCat467/Checkers" @@ -67,15 +65,52 @@ tests = [ "uv>=0.5.21", ] tools = [ - "mypy>=1.17.0", + "mypy>=1.18.2", "ruff>=0.9.2", "codespell>=2.3.0", "pre-commit>=4.2.0", "attrs>=25.3.0", ] -[tool.setuptools.package-data] -checkers = ["py.typed", "data/*"] +[tool.hatch.build.targets.wheel] +packages = ["src/checkers", "src/checkers_computer_players"] + +[tool.hatch.build.targets.wheel.hooks.mypyc] +dependencies = [ + "hatch-mypyc>=0.16.0", + "mypy>=1.18.2", +] +require-runtime-dependencies = true +include = [ + "src/checkers/base2d.py", + "src/checkers/network_shared.py", + "src/checkers/sound.py", + "src/checkers/state.py", + "src/checkers/server_state.py", + "src/checkers_computer_players/minimax.py", + "src/checkers_computer_players/checkers_minimax.py", +] +exclude = [ + "tests/test_statemachine.py", + "src/checkers/client.py", +## "src/checkers/element_list.py", +## "src/checkers/game.py", + "src/checkers/multi_inherit.py", +## "src/checkers/namedtuple_mod.py", +## "src/checkers/objects.py", + "src/checkers/server.py", +## "src/checkers/sprite.py", +## "src/checkers/statemachine.py", + "src/checkers/vector.py", +## "src/checkers_computer_players/example_ai.py", +## "src/checkers_computer_players/max_y_jumper_ai.py", + "src/checkers_computer_players/machine_client.py", +## "src/checkers_computer_players/minimax_ai.py", +] + +#[tool.hatch.build.targets.wheel.hooks.mypyc.options] +#opt_level = "1" +#multi_file = true [tool.uv] package = true @@ -165,6 +200,8 @@ extend-ignore = [ [tool.ruff.lint.per-file-ignores] "tests/*" = [ "D100", # undocumented-public-module + "D101", # undocumented-public-class + "D102", # undocumented-public-method "D103", # undocumented-public-function "D107", # undocumented-public-init ] diff --git a/src/checkers/__main__.py b/src/checkers/__main__.py new file mode 100644 index 0000000..08e48e7 --- /dev/null +++ b/src/checkers/__main__.py @@ -0,0 +1,23 @@ +"""Checkers Game Module.""" + +# Programmed by CoolCat467 + +# Copyright (C) 2023-2025 CoolCat467 +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +if __name__ == "__main__": + from checkers.game import cli_run as cli_run + + cli_run() diff --git a/src/checkers/client.py b/src/checkers/client.py index 0290bd1..d773e0b 100644 --- a/src/checkers/client.py +++ b/src/checkers/client.py @@ -64,11 +64,12 @@ async def read_advertisements( # SO_REUSEADDR: allows binding to port potentially already in use # Allow multiple copies of this program on one machine # (not strictly needed) - udp_socket.setsockopt( - trio.socket.SOL_SOCKET, - trio.socket.SO_REUSEADDR, - 1, - ) + if hasattr(trio.socket, "SO_REUSEADDR"): + udp_socket.setsockopt( + trio.socket.SOL_SOCKET, + trio.socket.SO_REUSEADDR, + 1, + ) await udp_socket.bind(("", ADVERTISEMENT_PORT)) @@ -101,7 +102,15 @@ async def read_advertisements( mreq, ) else: # IPv6 - mreq = group_bin + struct.pack("@I", 0) + # print( + # "\n".join( + # f"{iface_index}: {iface_name}" + # for iface_index, iface_name in trio.socket.if_nameindex() + # ) + # ) + # iface_index = socket.if_nametoindex(iface_name) + iface_index = 0 + mreq = group_bin + struct.pack("@I", iface_index) udp_socket.setsockopt( trio.socket.IPPROTO_IPV6, trio.socket.IPV6_JOIN_GROUP, @@ -152,7 +161,7 @@ class GameClient(ClientNetworkEventComponent): to the server, and reading and raising incoming events from the server. """ - __slots__ = ("connect_event_lock", "running") + # __slots__ = ("connect_event_lock", "running") def __init__(self, name: str) -> None: """Initialize GameClient.""" @@ -317,7 +326,9 @@ async def handle_client_connect( traceback.print_exception(ex) else: self.running = True - while not self.not_connected and self.running: + while self.running: + if self.not_connected: + break await self.handle_read_event() self.running = False diff --git a/src/checkers/element_list.py b/src/checkers/element_list.py index ce1a0d8..7aa5574 100644 --- a/src/checkers/element_list.py +++ b/src/checkers/element_list.py @@ -38,7 +38,7 @@ class Element(sprite.Sprite): """Element sprite.""" - __slots__ = () + # __slots__ = () def self_destruct(self) -> None: """Remove this element.""" @@ -55,7 +55,7 @@ def __del__(self) -> None: class ElementList(sprite.Sprite): """Element List sprite.""" - __slots__ = ("_order",) + # __slots__ = ("_order",) def __init__(self, name: object) -> None: """Initialize connection list.""" diff --git a/src/checkers/game.py b/src/checkers/game.py index 15e8205..8c4fba6 100644 --- a/src/checkers/game.py +++ b/src/checkers/game.py @@ -53,6 +53,7 @@ from checkers import base2d, element_list, objects, sprite from checkers.client import GameClient, read_advertisements +from checkers.multi_inherit import ConnectionElement, ReturnElement from checkers.network_shared import DEFAULT_PORT, Pos from checkers.objects import Button, OutlinedText from checkers.server import GameServer @@ -132,13 +133,13 @@ def render_text( class Piece(sprite.Sprite): """Piece Sprite.""" - __slots__ = ( - "board_position", - "destination_tiles", - "piece_type", - "position_name", - "selected", - ) + ## __slots__ = ( + ## "board_position", + ## "destination_tiles", + ## "piece_type", + ## "position_name", + ## "selected", + ## ) def __init__( self, @@ -282,7 +283,7 @@ async def handle_update_event(self, event: Event[int]) -> None: class Tile(sprite.Sprite): """Outlined tile sprite - Only exists for selecting destination.""" - __slots__ = ("board_position", "color") + # __slots__ = ("board_position", "color") def __init__( self, @@ -379,14 +380,14 @@ def play_sound( class GameBoard(sprite.Sprite): """Entity that stores data about the game board and renders it.""" - __slots__ = ( - "animation_queue", - "board_size", - "pieces", - "processing_animations", - "tile_size", - "tile_surfs", - ) + ## __slots__ = ( + ## "animation_queue", + ## "board_size", + ## "pieces", + ## "processing_animations", + ## "tile_size", + ## "tile_surfs", + ## ) # Define Tile Color Map and Piece Map # tile_color_map = (BLACK, RED) @@ -691,7 +692,7 @@ def generate_tile_images(self) -> None: else: image.add_image_and_mask(name, surface, "tile_0") - if index % 2 != 0: + if index & 1 != 0: continue outline_color = GREEN @@ -712,7 +713,7 @@ def generate_tile_images(self) -> None: color, ) - if piece_type % 2 == 0: + if piece_type & 1 == 0: image.add_image(name, surface) else: image.add_image_and_mask( @@ -812,7 +813,7 @@ def generate_board_image(self) -> Surface: class ClickDestinationComponent(Component): """Component that will use targeting to go to wherever you click on the screen.""" - __slots__ = ("selected",) + # __slots__ = ("selected",) outline = pygame.color.Color(255, 220, 0) def __init__(self) -> None: @@ -1014,7 +1015,7 @@ async def check_conditions(self) -> None: class GameState(AsyncState["CheckersClient"]): """Checkers Game Asynchronous State base class.""" - __slots__ = ("id", "manager") + # __slots__ = ("id", "manager") def __init__(self, name: str) -> None: """Initialize Game State.""" @@ -1224,7 +1225,7 @@ async def entry_actions(self) -> None: class PlayHostingState(AsyncState["CheckersClient"]): """Start running server.""" - __slots__ = ("address",) + # __slots__ = ("address",) internal_server = False @@ -1271,64 +1272,10 @@ class PlayInternalHostingState(PlayHostingState): internal_server = True -class ReturnElement(element_list.Element, objects.Button): - """Connection list return to title element sprite.""" - - __slots__ = () - - def __init__(self, name: str, font: pygame.font.Font) -> None: - """Initialize return element.""" - super().__init__(name, font) - - self.update_location_on_resize = False - self.border_width = 4 - self.outline = RED - self.text = "Return to Title" - self.visible = True - self.location = (SCREEN_SIZE[0] // 2, self.location.y + 10) - - async def handle_click( - self, - _: Event[sprite.PygameMouseButtonEventData], - ) -> None: - """Handle Click Event.""" - await self.raise_event( - Event("return_to_title", None, 2), - ) - - -class ConnectionElement(element_list.Element, objects.Button): - """Connection list element sprite.""" - - __slots__ = () - - def __init__( - self, - name: tuple[str, int], - font: pygame.font.Font, - motd: str, - ) -> None: - """Initialize connection element.""" - super().__init__(name, font) - - self.text = f"[{name[0]}:{name[1]}]\n{motd}" - self.visible = True - - async def handle_click( - self, - _: Event[sprite.PygameMouseButtonEventData], - ) -> None: - """Handle Click Event.""" - details = self.name - await self.raise_event( - Event("join_server", details, 2), - ) - - class PlayJoiningState(GameState): """Start running client.""" - __slots__ = ("font",) + # __slots__ = ("font",) def __init__(self) -> None: """Initialize Joining State.""" @@ -1360,6 +1307,8 @@ async def entry_actions(self) -> None: 30, ) return_button = ReturnElement("return_button", return_font) + return_button.outline = RED + return_button.location = (SCREEN_SIZE[0] // 2, 30) connections.add_element(return_button) self.manager.register_handlers( @@ -1431,7 +1380,7 @@ async def handle_return_to_title(self, _: Event[None]) -> None: class PlayState(GameState): """Game Play State.""" - __slots__ = ("exit_data",) + # __slots__ = ("exit_data",) def __init__(self) -> None: """Initialize Play State.""" @@ -1570,7 +1519,7 @@ async def do_actions(self) -> None: class CheckersClient(sprite.GroupProcessor): """Checkers Game Client.""" - __slots__ = ("manager",) + # __slots__ = ("manager",) def __init__(self, manager: ExternalRaiseManager) -> None: """Initialize Checkers Client.""" diff --git a/src/checkers/multi_inherit.py b/src/checkers/multi_inherit.py new file mode 100644 index 0000000..b72328e --- /dev/null +++ b/src/checkers/multi_inherit.py @@ -0,0 +1,90 @@ +"""Objects that inherit from multiple base classes because mypyc is dumb.""" + +# Programmed by CoolCat467 + +from __future__ import annotations + +# Objects that inherit from multiple base classes because mypyc is dumb +# Copyright (C) 2024 CoolCat467 +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +__title__ = "Multi-inherit Objects" +__author__ = "CoolCat467" +__version__ = "0.0.0" +__license__ = "GNU General Public License Version 3" + + +from typing import TYPE_CHECKING + +from libcomponent.component import Event + +from checkers import element_list, objects, sprite + +if TYPE_CHECKING: + import pygame + + from checkers import sprite + + +class ReturnElement(element_list.Element, objects.Button): + """Connection list return to title element sprite.""" + + __slots__ = () + + def __init__(self, name: str, font: pygame.font.Font) -> None: + """Initialize return element.""" + super().__init__(name, font) + + self.update_location_on_resize = False + self.border_width = 4 + self.text = "Return to Title" + self.visible = True + + async def handle_click( + self, + _: Event[sprite.PygameMouseButtonEventData], + ) -> None: + """Handle Click Event.""" + await self.raise_event( + Event("return_to_title", None, 2), + ) + + +class ConnectionElement(element_list.Element, objects.Button): + """Connection list element sprite.""" + + __slots__ = () + + def __init__( + self, + name: tuple[str, int], + font: pygame.font.Font, + motd: str, + ) -> None: + """Initialize connection element.""" + super().__init__(name, font) + + self.text = f"[{name[0]}:{name[1]}]\n{motd}" + self.visible = True + + async def handle_click( + self, + _: Event[sprite.PygameMouseButtonEventData], + ) -> None: + """Handle Click Event.""" + details = self.name + await self.raise_event( + Event("join_server", details, 2), + ) diff --git a/src/checkers/namedtuple_mod.py b/src/checkers/namedtuple_mod.py index a88bbd2..8611d8f 100644 --- a/src/checkers/namedtuple_mod.py +++ b/src/checkers/namedtuple_mod.py @@ -4,7 +4,10 @@ import typing +import mypy_extensions + +@mypy_extensions.mypyc_attr(native_class=False) class NamedTupleMeta(type): """NamedTuple Metaclass.""" @@ -62,13 +65,13 @@ def __new__( return nm_tpl -def apply_namedtuple_mod() -> None: # pragma: nocover - """Allow NamedTuple to subclass things other than just NamedTuple or Generic.""" - typing.NamedTupleMeta = NamedTupleMeta # type: ignore[attr-defined] - typing._NamedTuple = type.__new__(NamedTupleMeta, "NamedTuple", (), {}) # type: ignore[attr-defined] - - def _namedtuple_mro_entries(bases: tuple[type, ...]) -> tuple[type, ...]: - assert typing.NamedTuple in bases - return (typing._NamedTuple,) # type: ignore[attr-defined] - - typing.NamedTuple.__mro_entries__ = _namedtuple_mro_entries # type: ignore[attr-defined] +# def apply_namedtuple_mod() -> None: # pragma: nocover +# """Allow NamedTuple to subclass things other than just NamedTuple or Generic.""" +# typing.NamedTupleMeta = NamedTupleMeta # type: ignore[attr-defined] +# typing._NamedTuple = type.__new__(NamedTupleMeta, "NamedTuple", (), {}) # type: ignore[attr-defined] +# +# def _namedtuple_mro_entries(bases: tuple[type, ...]) -> tuple[type, ...]: +# assert typing.NamedTuple in bases +# return (typing._NamedTuple,) # type: ignore[attr-defined] +# +# typing.NamedTuple.__mro_entries__ = _namedtuple_mro_entries # type: ignore[attr-defined] diff --git a/src/checkers/server.py b/src/checkers/server.py index 975a112..0329fde 100755 --- a/src/checkers/server.py +++ b/src/checkers/server.py @@ -56,7 +56,8 @@ read_position, write_position, ) -from checkers.state import State, generate_pieces +from checkers.server_state import CheckersState +from checkers.state import generate_pieces if TYPE_CHECKING: from collections.abc import Awaitable, Callable, Iterable @@ -319,49 +320,6 @@ async def start_encryption_request(self) -> None: await self.handle_encryption_response(event) -class CheckersState(State): - """Subclass of State that keeps track of actions in `action_queue`.""" - - __slots__ = ("action_queue",) - - def __init__( - self, - size: Pos, - pieces: dict[Pos, int], - turn: bool = True, - ) -> None: - """Initialize Checkers State.""" - super().__init__(size, pieces, turn) - self.action_queue: deque[tuple[str, Iterable[Pos | int]]] = deque() - - def piece_kinged(self, piece_pos: Pos, new_type: int) -> None: - """Add king event to action queue.""" - super().piece_kinged(piece_pos, new_type) - self.action_queue.append(("king", (piece_pos, new_type))) - - def piece_moved(self, start_pos: Pos, end_pos: Pos) -> None: - """Add move event to action queue.""" - super().piece_moved(start_pos, end_pos) - self.action_queue.append( - ( - "move", - ( - start_pos, - end_pos, - ), - ), - ) - - def piece_jumped(self, jumped_piece_pos: Pos) -> None: - """Add jump event to action queue.""" - super().piece_jumped(jumped_piece_pos) - self.action_queue.append(("jump", (jumped_piece_pos,))) - - def get_action_queue(self) -> deque[tuple[str, Iterable[Pos | int]]]: - """Return action queue.""" - return self.action_queue - - class GameServer(network.Server): """Checkers server. @@ -507,7 +465,7 @@ def setup_teams(client_ids: list[int]) -> dict[int, int]: players: dict[int, int] = {} for idx, client_id in enumerate(client_ids): if idx < 2: - players[client_id] = idx % 2 + players[client_id] = idx & 1 else: players[client_id] = 0xFF # Spectator return players diff --git a/src/checkers/server_state.py b/src/checkers/server_state.py new file mode 100644 index 0000000..8cf14a0 --- /dev/null +++ b/src/checkers/server_state.py @@ -0,0 +1,86 @@ +"""Server State.""" + +# Programmed by CoolCat467 + +from __future__ import annotations + +# Server State +# Copyright (C) 2023-2025 CoolCat467 +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +__title__ = "Server State" +__author__ = "CoolCat467" +__version__ = "0.0.0" +__license__ = "GNU General Public License Version 3" + + +from collections import deque +from typing import TYPE_CHECKING + +from checkers.state import State + +if TYPE_CHECKING: + from collections.abc import Iterable + + from mypy_extensions import u8 + + from checkers.network_shared import Pos + + +class CheckersState(State): + """Subclass of State that keeps track of actions in `action_queue`.""" + + __slots__ = ("action_queue",) + + def __init__( + self, + size: Pos, + pieces: dict[Pos, u8], + turn: bool = True, + ) -> None: + """Initialize Checkers State.""" + super().__init__(size, pieces, turn) + self.action_queue: deque[tuple[str, Iterable[Pos | u8]]] = deque() + + def piece_kinged(self, piece_pos: Pos, new_type: u8) -> None: + """Add king event to action queue.""" + super().piece_kinged(piece_pos, new_type) + self.action_queue.append(("king", (piece_pos, new_type))) + + def piece_moved(self, start_pos: Pos, end_pos: Pos) -> None: + """Add move event to action queue.""" + super().piece_moved(start_pos, end_pos) + self.action_queue.append( + ( + "move", + ( + start_pos, + end_pos, + ), + ), + ) + + def piece_jumped(self, jumped_piece_pos: Pos) -> None: + """Add jump event to action queue.""" + super().piece_jumped(jumped_piece_pos) + self.action_queue.append(("jump", (jumped_piece_pos,))) + + def get_action_queue(self) -> deque[tuple[str, Iterable[Pos | u8]]]: + """Return action queue.""" + return self.action_queue + + +if __name__ == "__main__": + print(f"{__title__} v{__version__}\nProgrammed by {__author__}.\n") diff --git a/src/checkers/sprite.py b/src/checkers/sprite.py index dc4f3eb..e1e6083 100644 --- a/src/checkers/sprite.py +++ b/src/checkers/sprite.py @@ -73,7 +73,7 @@ class PygameMouseMotion(PygameMouseEventData): class Sprite(ComponentManager, WeakDirtySprite): """Client sprite component.""" - __slots__ = ("__image", "mask", "rect", "update_location_on_resize") + # __slots__ = ("__image", "mask", "rect", "update_location_on_resize") def __init__(self, name: object) -> None: """Initialize with name.""" diff --git a/src/checkers/state.py b/src/checkers/state.py index 364e618..70460b9 100644 --- a/src/checkers/state.py +++ b/src/checkers/state.py @@ -29,20 +29,25 @@ from dataclasses import dataclass from typing import ( TYPE_CHECKING, + Final, NamedTuple, TypeAlias, TypeVar, ) -from mypy_extensions import u8 +from mypy_extensions import i16, u8 if TYPE_CHECKING: from collections.abc import Callable, Generator, Iterable from typing_extensions import Self -MANDATORY_CAPTURE = True # If a jump is available, do you have to or not? -PAWN_JUMP_FORWARD_ONLY = True # Pawns not allowed to go backwards in jumps? +MANDATORY_CAPTURE: Final = ( + True # If a jump is available, do you have to or not? +) +PAWN_JUMP_FORWARD_ONLY: Final = ( + True # Pawns not allowed to go backwards in jumps? +) # Note: Tile Ids are chess board tile titles, A1 to H8 # A8 ... H8 @@ -76,13 +81,15 @@ class ActionSet(NamedTuple): def get_sides(xy: Pos) -> tuple[Pos, Pos, Pos, Pos]: """Return the tile xy coordinates on the top left, top right, bottom left, and bottom right sides of given xy coordinates.""" cx, cy = xy - sides = [] + cx_i16 = i16(cx) + cy_i16 = i16(cy) + sides: list[Pos] = [] for raw_dy in range(2): - dy = raw_dy * 2 - 1 - ny = cy + dy + dy: i16 = raw_dy * 2 - 1 + ny: u8 = u8(cy_i16 + dy) for raw_dx in range(2): dx = raw_dx * 2 - 1 - nx = cx + dx + nx = u8(cx_i16 + dx) sides.append((nx, ny)) tuple_sides = tuple(sides) assert len(tuple_sides) == 4 @@ -109,8 +116,8 @@ def pawn_modify(moves: tuple[T, ...], piece_type: u8) -> tuple[T, ...]: class State: """Represents state of checkers game.""" - size: tuple[int, int] - pieces: dict[Pos, int] + size: tuple[u8, u8] + pieces: dict[Pos, u8] turn: bool = True # Black moves first def __str__(self) -> str: @@ -121,7 +128,7 @@ def __str__(self) -> str: for y in range(h): line = [] for x in range(w): - if (x + y + 1) % 2: + if (x + y + 1) & 1 != 0: # line.append("_") line.append(" ") continue @@ -148,7 +155,7 @@ def calculate_actions(self, position: Pos) -> ActionSet: ends.update(moves) return ActionSet(jumps, moves, ends) - def piece_kinged(self, piece_pos: Pos, new_type: int) -> None: + def piece_kinged(self, piece_pos: Pos, new_type: u8) -> None: """Piece kinged.""" # print(f'piece_kinged {piece = }') @@ -210,7 +217,7 @@ def perform_action(self, action: Action) -> Self: not self.turn, ) - def get_tile_name(self, x: int, y: int) -> str: + def get_tile_name(self, x: u8, y: u8) -> str: """Return name of a given tile.""" return chr(65 + x) + str(self.size[1] - y) @@ -230,32 +237,32 @@ def valid_location(self, position: Pos) -> bool: w, h = self.size return x >= 0 and y >= 0 and x < w and y < h - def does_piece_king(self, piece_type: int, position: Pos) -> bool: + def does_piece_king(self, piece_type: u8, position: Pos) -> bool: """Return if piece needs to be kinged given it's type and position.""" _, y = position _, h = self.size return (piece_type == 0 and y == 0) or (piece_type == 1 and y == h - 1) @staticmethod - def get_enemy(self_type: int) -> int: + def get_enemy(self_type: u8) -> u8: """Return enemy pawn piece type.""" # If we are kinged, get a pawn version of ourselves. # Take that plus one mod 2 to get the pawn of the enemy - return (self_type + 1) % 2 + return (self_type + 1) & 1 @staticmethod - def get_piece_types(self_type: int) -> tuple[int, int]: + def get_piece_types(self_type: u8) -> tuple[u8, u8]: """Return piece types of given piece type.""" # If we are kinged, get a pawn version of ourselves. - self_pawn = self_type % 2 + self_pawn = self_type & 1 return (self_pawn, self_pawn + 2) def get_jumps( self, position: Pos, - piece_type: int | None = None, - _pieces: dict[Pos, int] | None = None, - _recursion: int = 0, + piece_type: u8 | None = None, + _pieces: dict[Pos, u8] | None = None, + _recursion: u8 = 0, ) -> dict[Pos, list[Pos]]: """Return valid jumps a piece can make. @@ -325,19 +332,20 @@ def get_jumps( # Get the dictionary from the jumps you could make # from that end tile w, h = self.size - if _recursion + 1 > math.ceil((w**2 + h**2) ** 0.25): + next_recursion = _recursion + 1 + if next_recursion > math.ceil((w**2 + h**2) ** 0.25): break # If the piece has made it to the opposite side, piece_type_copy = piece_type if self.does_piece_king(piece_type_copy, end_tile): # King that piece piece_type_copy += 2 - _recursion = -1 + next_recursion = 0 add_valid = self.get_jumps( end_tile, piece_type_copy, _pieces=_pieces, - _recursion=_recursion + 1, + _recursion=next_recursion, ) # For each key in the new dictionary of valid tile's keys, for end_pos, jumped_pieces in add_valid.items(): @@ -383,7 +391,7 @@ def get_actions(self, position: Pos) -> Generator[Action, None, None]: for end in ends: yield self.action_from_points(position, end) - def get_all_actions(self, player: int) -> Generator[Action, None, None]: + def get_all_actions(self, player: u8) -> Generator[Action, None, None]: """Yield all actions for given player.""" player_pieces = {player, player + 2} if not MANDATORY_CAPTURE: @@ -408,7 +416,7 @@ def get_all_actions(self, player: int) -> Generator[Action, None, None]: continue yield from self.wrap_actions(position, self.get_moves) - def check_for_win(self) -> int | None: + def check_for_win(self) -> u8 | None: """Return player number if they won else None.""" # For each of the two players, for player in range(2): @@ -421,28 +429,28 @@ def check_for_win(self) -> int | None: if not has_move and self.turn == bool(player): # Continued without break, so player either has no moves # or no possible moves, so their opponent wins - return (player + 1) % 2 + return (player + 1) & 1 return None - def can_player_select_piece(self, player: int, tile_pos: Pos) -> bool: + def can_player_select_piece(self, player: u8, tile_pos: Pos) -> bool: """Return True if player can select piece on given tile position.""" piece_at_pos = self.pieces.get(tile_pos) if piece_at_pos is None: return False - return (piece_at_pos % 2) == player + return (piece_at_pos & 1) == player - def get_pieces(self) -> tuple[tuple[Pos, int], ...]: + def get_pieces(self) -> tuple[tuple[Pos, u8], ...]: """Return all pieces.""" return tuple((pos, type_) for pos, type_ in self.pieces.items()) def generate_pieces( - board_width: int, - board_height: int, - colors: int = 2, -) -> dict[Pos, int]: + board_width: u8, + board_height: u8, + colors: u8 = 2, +) -> dict[Pos, u8]: """Generate data about each piece.""" - pieces: dict[Pos, int] = {} + pieces: dict[Pos, u8] = {} # Get where pieces should be placed z_to_1 = round(board_height / 3) # White z_to_2 = (board_height - (z_to_1 * 2)) + z_to_1 # Black @@ -453,8 +461,8 @@ def generate_pieces( # Get the color of that spot by adding x and y mod the number of different colors color = (x + y + 1) % colors # If a piece should be placed on that tile and the tile is not Red, - if (not color) and ((y <= z_to_1 - 1) or (y >= z_to_2)): + if (color == 0) and ((y <= z_to_1 - 1) or (y >= z_to_2)): # Set the piece to White Pawn or Black Pawn depending on the current y pos - piece_type = int(y <= z_to_1) + piece_type = u8(y <= z_to_1) pieces[x, y] = piece_type return pieces diff --git a/src/checkers_computer_players/__main__.py b/src/checkers_computer_players/__main__.py new file mode 100644 index 0000000..0a04bcf --- /dev/null +++ b/src/checkers_computer_players/__main__.py @@ -0,0 +1,31 @@ +"""Computer Players Module.""" + +# Programmed by CoolCat467 + +from __future__ import annotations + +# Computer Players Module +# Copyright (C) 2025 CoolCat467 +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +__title__ = "Computer Players Module" +__author__ = "CoolCat467" +__version__ = "0.0.0" +__license__ = "GNU General Public License Version 3" + +from checkers_computer_players.minimax_ai import run + +if __name__ == "__main__": + run() diff --git a/src/checkers_computer_players/checkers_minimax.py b/src/checkers_computer_players/checkers_minimax.py new file mode 100644 index 0000000..1d609a2 --- /dev/null +++ b/src/checkers_computer_players/checkers_minimax.py @@ -0,0 +1,312 @@ +"""Checkers Minimax.""" + +# Programmed by CoolCat467 + +from __future__ import annotations + +# Checkers Minimax +# Copyright (C) 2024-2025 CoolCat467 +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +__title__ = "Checkers Minimax" +__author__ = "CoolCat467" +__version__ = "0.0.0" +__license__ = "GNU General Public License Version 3" + + +import math +import random +import time +from collections import Counter +from math import inf as infinity +from typing import TYPE_CHECKING, Any, TypeVar + +from checkers.state import Action, State +from checkers_computer_players.minimax import ( + Minimax, + MinimaxResult, + Player, +) + +if TYPE_CHECKING: + from collections.abc import Iterable + + from mypy_extensions import u8 + +T = TypeVar("T") + +# Player: +# 0 = False = Person = MIN = 0, 2 +# 1 = True = AI (Us) = MAX = 1, 3 + + +class MinimaxWithTT(Minimax[State, Action]): + """Minimax with transposition table.""" + + __slots__ = ("transposition_table",) + + # Simple Transposition Table: + # key → (stored_depth, result, flag) + # flag: 'EXACT', 'LOWERBOUND', 'UPPERBOUND' + def __init__(self) -> None: + """Initialize this object.""" + super().__init__() + + self.transposition_table: dict[ + int, + tuple[u8, MinimaxResult[Any], str], + ] = {} + + def _transposition_table_lookup( + self, + state_hash: int, + depth: u8, + alpha: float, + beta: float, + ) -> MinimaxResult[Action] | None: + """Lookup in transposition_table. Return (value, action) or None.""" + entry = self.transposition_table.get(state_hash) + if entry is None: + return None + + stored_depth, result, flag = entry + # only use if stored depth is deep enough + if stored_depth >= depth and ( + (flag == "EXACT") + or (flag == "LOWERBOUND" and result.value > alpha) + or (flag == "UPPERBOUND" and result.value < beta) + ): + return result + return None + + def _transposition_table_store( + self, + state_hash: int, + depth: u8, + result: MinimaxResult[Action], + alpha: float, + beta: float, + ) -> None: + """Store in transposition_table with proper flag.""" + if result.value <= alpha: + flag = "UPPERBOUND" + elif result.value >= beta: + flag = "LOWERBOUND" + else: + flag = "EXACT" + self.transposition_table[state_hash] = (depth, result, flag) + + @classmethod + def hash_state(cls, state: State) -> int: + """Your state-to-hash function. Must be consistent.""" + # For small games you might do: return hash(state) + # For larger, use Zobrist or custom. + return hash(state) + + def alphabeta_transposition_table( + self, + state: State, + depth: u8 = 5, + a: float = -infinity, + b: float = infinity, + ) -> MinimaxResult[Action]: + """AlphaBeta with transposition table.""" + if self.terminal(state): + return MinimaxResult(self.value(state), None) + if depth <= 0: + # Choose a random action + # No need for cryptographic secure random + return MinimaxResult( + self.value(state), + random.choice(tuple(self.actions(state))), # noqa: S311 + ) + + state_h = self.hash_state(state) + # 1) Try transposition_table lookup + transposition_table_hit = self._transposition_table_lookup( + state_h, + depth, + a, + b, + ) + if transposition_table_hit is not None: + return transposition_table_hit + + next_down = depth - 1 + + current_player = self.player(state) + value: float + + best_action: Action | None = None + + if current_player == Player.MAX: + value = -infinity + for action in self.actions(state): + child = self.alphabeta_transposition_table( + self.result(state, action), + next_down, + a, + b, + ) + if child.value > value: + value = child.value + best_action = action + a = max(a, value) + if a >= b: + break + + elif current_player == Player.MIN: + value = infinity + for action in self.actions(state): + child = self.alphabeta_transposition_table( + self.result(state, action), + next_down, + a, + b, + ) + if child.value < value: + value = child.value + best_action = action + b = min(b, value) + if b <= a: + break + else: + raise NotImplementedError(f"{current_player = }") + + # 2) Store in transposition_table + result = MinimaxResult(value, best_action) + self._transposition_table_store(state_h, depth, result, a, b) + return result + + def iterative_deepening( + self, + state: State, + start_depth: u8 = 5, + max_depth: u8 = 7, + time_limit_ns: int | float | None = None, + ) -> MinimaxResult[Action]: + """Run alpha-beta with increasing depth up to max_depth. + + If time_limit_ns is None, do all depths. Otherwise stop early. + """ + best_result: MinimaxResult[Action] = MinimaxResult(0.0, None) + start_t = time.perf_counter_ns() + + for depth in range(start_depth, max_depth + 1): + # clear or keep transposition_table between depths? often you keep it + # self.transposition_table.clear() + + result = self.alphabeta_transposition_table( + state, + depth, + ) + best_result = result + + # Optional: if you find a forced win/loss you can stop + if abs(result.value) == self.HIGHEST: + print(f"reached terminal state stop {depth=}") + break + + # optional time check + if ( + time_limit_ns + and (time.perf_counter_ns() - start_t) > time_limit_ns + ): + print( + f"break from time expired {depth=} ({(time.perf_counter_ns() - start_t) / 1e9} seconds elaped)", + ) + break + print( + f"{depth=} ({(time.perf_counter_ns() - start_t) / 1e9} seconds elaped)", + ) + + return best_result + + +# Minimax[State, Action] +class CheckersMinimax(MinimaxWithTT): + """Minimax Algorithm for Checkers.""" + + __slots__ = () + + @classmethod + def hash_state(cls, state: State) -> int: + """Return state hash value.""" + # For small games you might do: return hash(state) + # For larger, use Zobrist or custom. + return hash((state.size, tuple(state.pieces.items()), state.turn)) + + @classmethod + def value(cls, state: State) -> float: + """Return value of given game state.""" + # Return winner if possible + win = state.check_for_win() + # If no winner, we have to predict the value + if win is None: + # We'll estimate the value by the pieces in play + counts = Counter(state.pieces.values()) + # Score is pawns plus 3 times kings + min_ = counts[0] + 3 * counts[2] + max_ = counts[1] + 3 * counts[3] + # More max will make score higher, + # more min will make score lower + # Plus one in divisor makes so never / 0 + return (max_ - min_) / (max_ + min_ + 1) + return float(win) * 2.0 - 1.0 + + @classmethod + def terminal(cls, state: State) -> bool: + """Return if game state is terminal.""" + return state.check_for_win() is not None + + @classmethod + def player(cls, state: State) -> Player: + """Return Player enum from current state's turn.""" + return Player.MAX if state.get_turn() else Player.MIN + + @classmethod + def actions(cls, state: State) -> Iterable[Action]: + """Return all actions that are able to be performed for the current player in the given state.""" + return state.get_all_actions(int(state.get_turn())) + + @classmethod + def result(cls, state: State, action: Action) -> State: + """Return new state after performing given action on given current state.""" + return state.perform_action(action) + + @classmethod + def adaptive_depth_minimax( + cls, + state: State, + minimum: int, + maximum: int, + ) -> MinimaxResult[Action]: + """Return minimax result from adaptive max depth.""" + ## types = state.pieces.values() + ## current = len(types) + ## w, h = state.size + ## max_count = w * h // 6 << 1 + ## old_depth = (1 - (current / max_count)) * math.floor( + ## math.sqrt(w**2 + h**2) + ## ) + + depth = cls.value(state) * maximum + minimum + final_depth = min(maximum, max(minimum, math.floor(depth))) + print(f"{depth = } {final_depth = }") + return cls.minimax(state, final_depth) + + +if __name__ == "__main__": + print(f"{__title__} v{__version__}\nProgrammed by {__author__}.\n") diff --git a/src/checkers_computer_players/minimax.py b/src/checkers_computer_players/minimax.py index c0de284..08bac3a 100644 --- a/src/checkers_computer_players/minimax.py +++ b/src/checkers_computer_players/minimax.py @@ -13,11 +13,13 @@ from abc import ABC, abstractmethod from enum import IntEnum, auto from math import inf as infinity -from typing import TYPE_CHECKING, Generic, NamedTuple, TypeVar +from typing import TYPE_CHECKING, ClassVar, Generic, NamedTuple, TypeVar if TYPE_CHECKING: from collections.abc import Callable, Iterable + from mypy_extensions import u8 + class Player(IntEnum): """Enum for player status.""" @@ -35,7 +37,7 @@ class Player(IntEnum): class MinimaxResult(NamedTuple, Generic[Action]): """Minimax Result.""" - value: int | float + value: float action: Action | None @@ -44,12 +46,12 @@ class Minimax(ABC, Generic[State, Action]): __slots__ = () - LOWEST = -1 - HIGHEST = 1 + LOWEST: ClassVar[float] = -1.0 + HIGHEST: ClassVar[float] = 1.0 @classmethod @abstractmethod - def value(cls, state: State) -> int | float: + def value(cls, state: State) -> float: """Return the value of a given game state. Should be in range [cls.LOWEST, cls.HIGHEST]. @@ -91,7 +93,7 @@ def probability(cls, action: Action) -> float: def minimax( cls, state: State, - depth: int | None = 5, + depth: u8 | None = 5, ) -> MinimaxResult[Action]: """Return minimax result best action for a given state for the current player.""" if cls.terminal(state): @@ -106,8 +108,8 @@ def minimax( next_down = None if depth is None else depth - 1 current_player = cls.player(state) - value: int | float - best: Callable[[int | float, int | float], int | float] + value: float + best: Callable[[float, float], float] if current_player == Player.MAX: value = -infinity best = max @@ -115,7 +117,7 @@ def minimax( value = infinity best = min elif current_player == Player.CHANCE: - value = 0 + value = 0.0 best = sum # type: ignore[assignment] else: raise ValueError(f"Unexpected player type {current_player!r}") @@ -123,8 +125,9 @@ def minimax( best_action: Action | None = None for action in cls.actions(state): result = cls.minimax(cls.result(state, action), next_down) - result_value = result.value + result_value: float = result.value if current_player == Player.CHANCE: + result_value = float(result_value) # Probability[action] result_value *= cls.probability(action) new_value = best(value, result_value) @@ -137,9 +140,9 @@ def minimax( def alphabeta( cls, state: State, - depth: int | None = 5, - a: int | float = -infinity, - b: int | float = infinity, + depth: u8 | None = 5, + a: float = -infinity, + b: float = infinity, ) -> MinimaxResult[Action]: """Return minimax alphabeta pruning result best action for given current state.""" # print(f'alphabeta {depth = } {a = } {b = }') @@ -156,8 +159,9 @@ def alphabeta( next_down = None if depth is None else depth - 1 current_player = cls.player(state) - value: int | float + value: float best: Callable[[int | float, int | float], int | float] + set_idx: u8 if current_player == Player.MAX: value = -infinity best = max @@ -169,15 +173,15 @@ def alphabeta( compare = operator.lt # less than (<) set_idx = 1 elif current_player == Player.CHANCE: - value = 0 + value = 0.0 best = sum # type: ignore[assignment] else: raise ValueError(f"Unexpected player type {current_player!r}") actions = tuple(cls.actions(state)) successors = len(actions) - expect_a = successors * (a - cls.HIGHEST) + cls.HIGHEST - expect_b = successors * (b - cls.LOWEST) + cls.LOWEST + expect_a = successors * (float(a) - cls.HIGHEST) + cls.HIGHEST + expect_b = successors * (float(b) - cls.LOWEST) + cls.LOWEST best_action: Action | None = None for action in actions: @@ -192,7 +196,7 @@ def alphabeta( ax, bx, ) - score = result.value + score = float(result.value) # Check for a, b cutoff conditions if score <= expect_a: return MinimaxResult(a, None) diff --git a/src/checkers_computer_players/minimax_ai.py b/src/checkers_computer_players/minimax_ai.py index a622c36..d82268f 100755 --- a/src/checkers_computer_players/minimax_ai.py +++ b/src/checkers_computer_players/minimax_ai.py @@ -3,35 +3,41 @@ """Minimax Checkers AI.""" +# Programmed by CoolCat467 + from __future__ import annotations -# Programmed by CoolCat467 +# Minimax Checkers AI +# Copyright (C) 2024-2025 CoolCat467 +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . __title__ = "Minimax AI" __author__ = "CoolCat467" __version__ = "0.0.0" -import math -import random -import time import traceback -from collections import Counter -from math import inf as infinity -from typing import TYPE_CHECKING, Any, ClassVar, TypeVar +from typing import TYPE_CHECKING, TypeVar -from checkers.state import Action, State +from checkers_computer_players.checkers_minimax import CheckersMinimax from checkers_computer_players.machine_client import ( RemoteState, run_clients_in_local_servers_sync, ) -from checkers_computer_players.minimax import ( - Minimax, - MinimaxResult, - Player, -) if TYPE_CHECKING: - from collections.abc import Iterable + from checkers.state import Action T = TypeVar("T") @@ -40,265 +46,16 @@ # 1 = True = AI (Us) = MAX = 1, 3 -class MinimaxWithID(Minimax[State, Action]): - """Minimax with ID.""" - - __slots__ = () - - # Simple Transposition Table: - # key → (stored_depth, value, action, flag) - # flag: 'EXACT', 'LOWERBOUND', 'UPPERBOUND' - TRANSPOSITION_TABLE: ClassVar[ - dict[int, tuple[int, MinimaxResult[Any], str]] - ] = {} - - @classmethod - def _transposition_table_lookup( - cls, - state_hash: int, - depth: int, - alpha: float, - beta: float, - ) -> MinimaxResult[Action] | None: - """Lookup in transposition_table. Return (value, action) or None.""" - entry = cls.TRANSPOSITION_TABLE.get(state_hash) - if entry is None: - return None - - stored_depth, result, flag = entry - # only use if stored depth is deep enough - if stored_depth >= depth and ( - (flag == "EXACT") - or (flag == "LOWERBOUND" and result.value > alpha) - or (flag == "UPPERBOUND" and result.value < beta) - ): - return result - return None - - @classmethod - def _transposition_table_store( - cls, - state_hash: int, - depth: int, - result: MinimaxResult[Action], - alpha: float, - beta: float, - ) -> None: - """Store in transposition_table with proper flag.""" - if result.value <= alpha: - flag = "UPPERBOUND" - elif result.value >= beta: - flag = "LOWERBOUND" - else: - flag = "EXACT" - cls.TRANSPOSITION_TABLE[state_hash] = (depth, result, flag) - - @classmethod - def hash_state(cls, state: State) -> int: - """Your state-to-hash function. Must be consistent.""" - # For small games you might do: return hash(state) - # For larger, use Zobrist or custom. - return hash(state) - - @classmethod - def alphabeta_transposition_table( - cls, - state: State, - depth: int = 5, - a: int | float = -infinity, - b: int | float = infinity, - ) -> MinimaxResult[Action]: - """AlphaBeta with transposition table.""" - if cls.terminal(state): - return MinimaxResult(cls.value(state), None) - if depth <= 0: - # Choose a random action - # No need for cryptographic secure random - return MinimaxResult( - cls.value(state), - random.choice(tuple(cls.actions(state))), # noqa: S311 - ) - next_down = depth - 1 - - state_h = cls.hash_state(state) - # 1) Try transposition_table lookup - transposition_table_hit = cls._transposition_table_lookup( - state_h, - depth, - a, - b, - ) - if transposition_table_hit is not None: - return transposition_table_hit - next_down = None if depth is None else depth - 1 - - current_player = cls.player(state) - value: int | float - - best_action: Action | None = None - - if current_player == Player.MAX: - value = -infinity - for action in cls.actions(state): - child = cls.alphabeta_transposition_table( - cls.result(state, action), - next_down, - a, - b, - ) - if child.value > value: - value = child.value - best_action = action - a = max(a, value) - if a >= b: - break - - elif current_player == Player.MIN: - value = infinity - for action in cls.actions(state): - child = cls.alphabeta_transposition_table( - cls.result(state, action), - next_down, - a, - b, - ) - if child.value < value: - value = child.value - best_action = action - b = min(b, value) - if b <= a: - break - else: - raise NotImplementedError(f"{current_player = }") - - # 2) Store in transposition_table - result = MinimaxResult(value, best_action) - cls._transposition_table_store(state_h, depth, result, a, b) - return result - - @classmethod - def iterative_deepening( - cls, - state: State, - start_depth: int = 5, - max_depth: int = 7, - time_limit_ns: int | float | None = None, - ) -> MinimaxResult[Action]: - """Run alpha-beta with increasing depth up to max_depth. - - If time_limit_ns is None, do all depths. Otherwise stop early. - """ - best_result: MinimaxResult[Action] = MinimaxResult(0, None) - start_t = time.perf_counter_ns() - - for depth in range(start_depth, max_depth + 1): - # clear or keep transposition_table between depths? often you keep it - # cls.TRANSPOSITION_TABLE.clear() - - result = cls.alphabeta_transposition_table( - state, - depth, - ) - best_result = result - - # Optional: if you find a forced win/loss you can stop - if abs(result.value) == cls.HIGHEST: - print(f"reached terminal state stop {depth=}") - break - - # optional time check - if ( - time_limit_ns - and (time.perf_counter_ns() - start_t) > time_limit_ns - ): - print( - f"break from time expired {depth=} ({(time.perf_counter_ns() - start_t) / 1e9} seconds elaped)", - ) - break - print( - f"{depth=} ({(time.perf_counter_ns() - start_t) / 1e9} seconds elaped)", - ) - - return best_result - - -# Minimax[State, Action] -class CheckersMinimax(MinimaxWithID): - """Minimax Algorithm for Checkers.""" - - __slots__ = () - - @classmethod - def hash_state(cls, state: State) -> int: - """Return state hash value.""" - # For small games you might do: return hash(state) - # For larger, use Zobrist or custom. - return hash((state.size, tuple(state.pieces.items()), state.turn)) - - @staticmethod - def value(state: State) -> int | float: - """Return value of given game state.""" - # Return winner if possible - win = state.check_for_win() - # If no winner, we have to predict the value - if win is None: - # We'll estimate the value by the pieces in play - counts = Counter(state.pieces.values()) - # Score is pawns plus 3 times kings - min_ = counts[0] + 3 * counts[2] - max_ = counts[1] + 3 * counts[3] - # More max will make score higher, - # more min will make score lower - # Plus one in divisor makes so never / 0 - return (max_ - min_) / (max_ + min_ + 1) - return win * 2 - 1 - - @staticmethod - def terminal(state: State) -> bool: - """Return if game state is terminal.""" - return state.check_for_win() is not None - - @staticmethod - def player(state: State) -> Player: - """Return Player enum from current state's turn.""" - return Player.MAX if state.get_turn() else Player.MIN - - @staticmethod - def actions(state: State) -> Iterable[Action]: - """Return all actions that are able to be performed for the current player in the given state.""" - return state.get_all_actions(int(state.get_turn())) - - @staticmethod - def result(state: State, action: Action) -> State: - """Return new state after performing given action on given current state.""" - return state.perform_action(action) - - @classmethod - def adaptive_depth_minimax( - cls, - state: State, - minimum: int, - maximum: int, - ) -> MinimaxResult[Action]: - """Return minimax result from adaptive max depth.""" - ## types = state.pieces.values() - ## current = len(types) - ## w, h = state.size - ## max_count = w * h // 6 << 1 - ## old_depth = (1 - (current / max_count)) * math.floor( - ## math.sqrt(w**2 + h**2) - ## ) - - depth = cls.value(state) * maximum + minimum - final_depth = min(maximum, max(minimum, math.floor(depth))) - print(f"{depth = } {final_depth = }") - return cls.minimax(state, final_depth) - - class MinimaxPlayer(RemoteState): """Minimax Player.""" - __slots__ = () + __slots__ = ("minimax",) + + def __init__(self) -> None: + """Initialize minimax player.""" + super().__init__() + + self.minimax = CheckersMinimax() async def perform_turn(self) -> Action: """Perform turn.""" @@ -308,7 +65,7 @@ async def perform_turn(self) -> Action: ##) ##value, action = CheckersMinimax.minimax(self.state, 4) ##value, action = CheckersMinimax.alphabeta(self.state, 4) - value, action = CheckersMinimax.iterative_deepening( + value, action = self.minimax.iterative_deepening( self.state, 4, 20, diff --git a/tests/test_sprite.py b/tests/test_sprite.py index 83e909a..5687b65 100644 --- a/tests/test_sprite.py +++ b/tests/test_sprite.py @@ -194,7 +194,7 @@ def test_movement_component_point_toward( def test_movement_component_move_heading_time( movement_component: MovementComponent, ) -> None: - movement_component.speed = 5 + movement_component.speed = 5.0 movement_component.move_heading_time(1) assert movement_component.heading * 5 == movement_component.heading diff --git a/tests/test_statemachine.py b/tests/test_statemachine.py index 0898145..8af5ed3 100644 --- a/tests/test_statemachine.py +++ b/tests/test_statemachine.py @@ -125,6 +125,14 @@ async def test_async_state_machine_add() -> None: assert bob.machine is not None +class ToBob(AsyncState[AsyncStateMachine]): + __slots__ = () + + async def check_conditions(self) -> str: + await super().check_conditions() + return "bob" + + @pytest.mark.trio async def test_async_state_machine_think() -> None: machine = AsyncStateMachine() @@ -145,13 +153,6 @@ async def test_async_state_machine_think() -> None: await machine.set_state("bob") machine.add_state(AsyncState("bob")) - class ToBob(AsyncState[AsyncStateMachine]): - __slots__ = () - - async def check_conditions(self) -> str: - await super().check_conditions() - return "bob" - machine.add_state(ToBob("tom")) await machine.set_state("tom") await machine.think() diff --git a/tools/build_direct.py b/tools/build_direct.py new file mode 100755 index 0000000..49c9f42 --- /dev/null +++ b/tools/build_direct.py @@ -0,0 +1,70 @@ +#!/usr/bin/env python3 + +"""TITLE - DESCRIPTION.""" + +# Programmed by CoolCat467 + +from __future__ import annotations + +# TITLE - DESCRIPTION +# Copyright (C) 2025 CoolCat467 +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +__title__ = "TITLE" +__author__ = "CoolCat467" +__version__ = "0.0.0" +__license__ = "GNU General Public License Version 3" + +import sys +from pathlib import Path +from typing import Final + +import tomllib + +KEY: Final = "tool.hatch.build.targets.wheel.hooks.mypyc" + +HERE: Final = Path(__file__).absolute().parent +ROOT: Final = HERE.parent +PYPROJECT_TOML: Final = ROOT / "pyproject.toml" + + +def run() -> int: + """Run program.""" + assert (ROOT / "LICENSE").exists() + + with PYPROJECT_TOML.open("rb") as fp: + pyproject = tomllib.load(fp) + + temp: dict[str, object] = pyproject + for key in KEY.split("."): + temp = temp[key] + mypyc_data = temp + + exclude = [f"--exclude={e!r}" for e in sorted(mypyc_data["exclude"])] + + command = ( + # "MYPYC_MULTI_FILE=1", + "mypyc", + *exclude, + "-a 'warnings.html'", + ) + + print(" ".join(command)) + + return 0 + + +if __name__ == "__main__": + sys.exit(run()) diff --git a/uv.lock b/uv.lock index b1551df..012c1a1 100644 --- a/uv.lock +++ b/uv.lock @@ -137,7 +137,7 @@ requires-dist = [ { name = "coverage", marker = "extra == 'tests'", specifier = ">=7.2.5" }, { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, { name = "libcomponent", specifier = "~=0.0.3" }, - { name = "mypy", marker = "extra == 'tools'", specifier = ">=1.17.0" }, + { name = "mypy", marker = "extra == 'tools'", specifier = ">=1.18.2" }, { name = "mypy-extensions", specifier = ">=1.0.0" }, { name = "pre-commit", marker = "extra == 'tools'", specifier = ">=4.2.0" }, { name = "pygame", specifier = "~=2.6.0" },