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" },