diff --git a/.github/workflows/python-app.yml b/.github/workflows/python-app.yml index 7ebabbf..1bb5a45 100644 --- a/.github/workflows/python-app.yml +++ b/.github/workflows/python-app.yml @@ -16,6 +16,8 @@ jobs: steps: - uses: actions/checkout@v3 + - name: Checkout submodules + run: git submodule update --init --recursive - name: Set up Python 3.10 uses: actions/setup-python@v3 with: @@ -28,7 +30,7 @@ jobs: if [ -f requirements.txt ]; then pip install -r requirements.txt; fi # install the tilewe package locally - python -m pip install -e ./tilewe + python -m pip install . - name: Lint with flake8 run: | # stop the build if there are Python syntax errors or undefined names diff --git a/.gitignore b/.gitignore index 1204559..764c840 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ tmp/ +.vscode/ # Byte-compiled / optimized / DLL files __pycache__/ diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..08c1bd8 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "tilewe/src/tilewe"] + path = tilewe/src/tilewe + url = https://github.com/nhamil/tilewe diff --git a/example_tournament.py b/example_tournament.py index 4ed0339..79eb8e5 100644 --- a/example_tournament.py +++ b/example_tournament.py @@ -14,7 +14,7 @@ def run_tournament(): tilewe.engine.RandomEngine(), ]) - results = tournament.play(100, n_threads=multiprocessing.cpu_count(), move_seconds=15) + results = tournament.play(100, n_threads=multiprocessing.cpu_count(), move_seconds=1, elo_mode="estimated") # print the result of game 1 print(results.match_data[0].board) diff --git a/tilewe/pyproject.toml b/pyproject.toml similarity index 76% rename from tilewe/pyproject.toml rename to pyproject.toml index a533b8f..a64412c 100644 --- a/tilewe/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "tilewe" -version = "0.0.1" +version = "0.0.2" authors = [ { name="Nicholas Hamilton", email="nh.contact.1@gmail.com" }, { name="Michael Conard", email="michaelaconard314@gmail.com" }, @@ -16,3 +16,8 @@ classifiers = [ [project.urls] "Github" = "https://github.com/nhamil/python-tilewe" +"C Implementation" = "https://github.com/nhamil/tilewe" + +[build-system] +requires = ["setuptools"] +build-backend = "setuptools.build_meta" \ No newline at end of file diff --git a/requirements-dev.txt b/requirements-dev.txt index aad120b..b121004 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -1,2 +1,3 @@ +wheel flake8 pytest \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index 221af1b..e69de29 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1 +0,0 @@ -numpy==1.26.2 \ No newline at end of file diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..e9b031b --- /dev/null +++ b/setup.py @@ -0,0 +1,24 @@ +from setuptools import setup, find_packages, Extension + +ext_modules = [ + Extension( + name="ctilewe", + sources=[ + "tilewe/src/ctilewemodule.c", + "tilewe/src/tilewe/Source/Tilewe/Piece.c", + "tilewe/src/tilewe/Source/Tilewe/Tables.c" + ], + include_dirs=[ + "tilewe/src/tilewe/Source", + "tilewe/src/tilewe/Source/Tilewe" + ], + extra_compile_args=["-O3", "-funroll-loops"] + ) +] + +setup( + name='tilewe', + version='0.0.2', + packages=find_packages(), + ext_modules=ext_modules +) diff --git a/tilewe/__init__.py b/tilewe/__init__.py index 55301bc..408d817 100644 --- a/tilewe/__init__.py +++ b/tilewe/__init__.py @@ -1,19 +1,12 @@ -from dataclasses import dataclass -import sys - -import numpy as np +import sys if sys.version_info[0] != 3 or sys.version_info[1] < 10: raise Exception("Requires Python 3.10+") -print_color = True - -# internally int, so copies value not reference Tile = int Piece = int Rotation = int Color = int -_PrpSet = int # game details related constant declarations TILES = [ @@ -68,25 +61,12 @@ "a20", "b20", "c20", "d20", "e20", "f20", "g20", "h20", "i20", "j20", "k20", "l20", "m20", "n20", "o20", "p20", "q20", "r20", "s20", "t20" # noqa: 501 ] -def tile_to_coords(tile: Tile) -> tuple[int, int]: - return TILE_COORDS[tile] - -def coords_to_tile(coords: tuple[int, int]) -> Tile: - return coords[0] + coords[1] * 20 - -def tile_to_index(tile: Tile) -> int: - return tile - -def out_of_bounds(coords: tuple[int, int]) -> int: - return not (0 <= coords[0] < 20 and 0 <= coords[1] < 20) - -def in_bounds(coords: tuple[int, int]) -> bool: - return 0 <= coords[0] < 20 and 0 <= coords[1] < 20 - ROTATIONS = [ NORTH, EAST, SOUTH, WEST, NORTH_F, EAST_F, SOUTH_F, WEST_F ] = [Rotation(x) for x in range(8)] +ROTATION_COUNT: int = len(ROTATIONS) + ROTATION_NAMES = [ 'n', 'e', 's', 'w', 'nf', 'ef', 'sf', 'wf' ] @@ -95,989 +75,183 @@ def in_bounds(coords: tuple[int, int]) -> bool: BLUE, YELLOW, RED, GREEN ] = [Color(x) for x in range(4)] -NO_COLOR: Color = -1 +NO_COLOR: Color = Color(len(COLORS)) + +COLOR_COUNT: int = len(COLORS) COLOR_NAMES = [ 'blue', 'yellow', 'red', 'green' ] -class _Piece: - """ - Internal/private representation of a game piece - including all rotations data. - - Parameters - ---------- - name : str - The name/identifier for the piece - id : Piece - The id for the piece - """ - - def __init__(self, name: str, id: Piece): - self.name = name - self.id = id - self.rotations: list[_PieceRotation] = [] - self.unique: list[bool] = [] - self.true_rot: list[Rotation] = [] - self.true_rot_for: list[list[Rotation]] = [] - -class _PieceRotation: - """ - Internal/private representation of a piece given a choice - of rotation. - - Parameters - ---------- - name : str - The name/identifier for this piece + rotation - pc : _Piece - A reference to the underlying internal piece data - rot : Rotation - Which rotation of the piece this piece-rotation has - shape : np.ndarray - The 2D matrix depicting the shape of this piece given the rotation - """ - - def __init__(self, name: str, pc: _Piece, rot: Rotation, shape: np.ndarray): - self.id = len(_PIECE_ROTATIONS) - self.piece = pc - self.rotation = rot - _PIECE_ROTATIONS.append(self) - - self.name = name - self.shape = np.array(shape, dtype=np.uint8) - - # coords relative to a1 of the rotated piece (regardless of if that's a valid contact) - self.rel_tiles: list[tuple[int, int]] = [] - self.rel_contacts: list[tuple[int, int]] = [] - - self.prps: dict[Tile, _PieceRotationPoint] = {} - self.n_corners = 0 - - self.contact_shape = np.zeros_like(shape, dtype=np.uint8) - H, W = shape.shape - for y in range(H): - for x in range(W): - # check each tile in piece - if shape[y, x] != 0: - self.rel_tiles.append((x, y)) - v_neighbors = 0 - h_neighbors = 0 - - if y > 0 and shape[y - 1, x] != 0: v_neighbors += 1 # noqa: E272, E701 - if y < H - 1 and shape[y + 1, x] != 0: v_neighbors += 1 # noqa: E701 - if x > 0 and shape[y, x - 1] != 0: h_neighbors += 1 # noqa: E272, E701 - if x < W - 1 and shape[y, x + 1] != 0: h_neighbors += 1 # noqa: E701 - - n_neighbors = v_neighbors + h_neighbors - - if (n_neighbors <= 1) or (v_neighbors == 1 and h_neighbors == 1): - self.rel_contacts.append((x, y)) - self.contact_shape[y, x] = 1 - - for coord in self.rel_contacts: - self.prps[coords_to_tile(coord)] = _PieceRotationPoint(name, self, coord) - - self.n_corners = len(list(self.prps.values())[0].rel_corners) - self.tiles = [coords_to_tile(t) for t in self.rel_tiles] - self.contacts = [coords_to_tile(t) for t in self.rel_contacts] - -class _PieceRotationPoint: - """ - Internal/private of a piece given a rotation and a contact - point on that piece. - - Parameters - ---------- - name : str - The name/identifier for this piece + rotation - rot : _PieceRotation - A reference to the underlying piece + rotation internal data - pt : Tile - Which contact of the piece is used by this piece-rotation-point - """ - - def __init__(self, name: str, rot: _PieceRotation, pt: tuple[int, int]): - self.id = len(_PIECE_ROTATION_POINTS) - self.as_set = 1 << self.id - global _PRP_SET_ALL - _PRP_SET_ALL |= self.as_set - self.piece = rot.piece - self.rotation = rot - self.piece_id = self.piece.id - self.name = name - self.contact = coords_to_tile(pt) - _PIECE_ROTATION_POINTS.append(self) - - dx, dy = pt - - # coords relative to the contact - self.rel_tiles: list[tuple[int, int]] = [] - self.rel_adjacent: set[tuple[int, int]] = set() - self.rel_corners: set[tuple[int, int]] = set() - - for x, y in rot.rel_tiles: - self.rel_tiles.append((x - dx, y - dy)) - - for x, y in self.rel_tiles: - for cy, cx in [(-1, 0), (1, 0), (0, -1), (0, 1)]: - rel = (x + cx, y + cy) - if rel not in self.rel_tiles: - self.rel_adjacent.add(rel) - - for x, y in self.rel_tiles: - for cx, cy in [(-1, -1), (1, -1), (-1, 1), (1, 1)]: - rel = (x + cx, y + cy) - if rel not in self.rel_tiles and rel not in self.rel_adjacent: - self.rel_corners.add(rel) - - self.rel_adjacent = sorted(list(self.rel_adjacent)) - self.rel_corners = sorted(list(self.rel_corners)) - -# internal global data for all game pieces -# initialized on library load one time below -N_PIECES = 0 -_PIECES: list[_Piece] = [] -_PIECE_ROTATIONS: list[_PieceRotation] = [] -_PIECE_ROTATION_POINTS: list[_PieceRotationPoint] = [] -_PRP_SET_ALL: _PrpSet = 0 - -def _create_piece(name: str, shape: list[list[int]]) -> Piece: - """ - Adds a piece to the game data, including all rotation and - horizontal flips of that piece. - - Parameters - ---------- - name : str - The name/identifier of the piece, used in notation - shape : list[list[int]] - A two dimensional matrix outlining the shape of the piece - - Returns - ------- - id : Piece - The new id assigned to this piece - """ - - global N_PIECES - id = N_PIECES - N_PIECES += 1 - pc = _Piece(name, id) - _PIECES.append(pc) - f_names = [] - - def add(suffix: str, arr: np.ndarray): - """ - Helper to add a variant of a piece, accounting - for uniqueness across rotations and horizontal flips. - """ - - rot = None - unique = True - cur_rot = len(pc.rotations) - true_rot = cur_rot # assume rotation is unique - - for x in pc.rotations: - if x.shape.shape == arr.shape and np.all(x.shape == arr): - rot = x - unique = False - true_rot = x.rotation - break - - if rot is None: - rot = _PieceRotation(name + suffix, pc, len(pc.rotations), arr) - - f_names.append(suffix + "f") - pc.rotations.append(rot) - pc.unique.append(unique) - pc.true_rot.append(true_rot) - pc.true_rot_for.append([]) - pc.true_rot_for[true_rot].append(cur_rot) - - # original shape, north - cur = np.array(shape, dtype=np.uint8)[::-1] - add("n", cur) - - # east - cur = np.rot90(cur, 1) - add("e", cur) +PIECES = [ + O1, I2, I3, L3, O4, I4, L4, + Z4, T4, F5, I5, L5, N5, P5, + T5, U5, V5, W5, X5, Y5, Z5, +] = [Piece(x) for x in range(21)] - # south - cur = np.rot90(cur, 1) - add("s", cur) +NO_PIECE: Piece = Piece(len(PIECES)) - # west - cur = np.rot90(cur, 1) - add("w", cur) +PIECE_COUNT: int = len(PIECES) - # flipped - n_unflipped = len(pc.rotations) - for i in range(n_unflipped): - add(f_names[i], np.fliplr(pc.rotations[i].shape)) - - return id - -# setup the 21 piece shapes -O1 = _create_piece("O1", [ - [1] -]) - -I2 = _create_piece("I2", [ - [1], - [1] -]) - -I3 = _create_piece("I3", [ - [1], - [1], - [1] -]) -L3 = _create_piece("L3", [ - [1, 0], - [1, 1] -]) - -I4 = _create_piece("I4", [ - [1], - [1], - [1], - [1] -]) -L4 = _create_piece("L4", [ - [1, 0], - [1, 0], - [1, 1] -]) -Z4 = _create_piece("Z4", [ - [1, 1, 0], - [0, 1, 1] -]) -O4 = _create_piece("O4", [ - [1, 1], - [1, 1] -]) -T4 = _create_piece("T4", [ - [1, 1, 1], - [0, 1, 0] -]) - -F5 = _create_piece("F5", [ - [0, 1, 1], - [1, 1, 0], - [0, 1, 0] -]) -I5 = _create_piece("I5", [ - [1], - [1], - [1], - [1], - [1] -]) -L5 = _create_piece("L5", [ - [1, 0], - [1, 0], - [1, 0], - [1, 1] -]) -N5 = _create_piece("N5", [ - [0, 1], - [1, 1], - [1, 0], - [1, 0] -]) -P5 = _create_piece("P5", [ - [1, 1], - [1, 1], - [1, 0] -]) -T5 = _create_piece("T5", [ - [1, 1, 1], - [0, 1, 0], - [0, 1, 0] -]) -U5 = _create_piece("U5", [ - [1, 0, 1], - [1, 1, 1] -]) -V5 = _create_piece("V5", [ - [0, 0, 1], - [0, 0, 1], - [1, 1, 1] -]) -W5 = _create_piece("W5", [ - [0, 0, 1], - [0, 1, 1], - [1, 1, 0] -]) -X5 = _create_piece("X5", [ - [0, 1, 0], - [1, 1, 1], - [0, 1, 0] -]) -Y5 = _create_piece("Y5", [ - [0, 1], - [1, 1], - [0, 1], - [0, 1] -]) -Z5 = _create_piece("Z5", [ - [1, 1, 0], - [0, 1, 0], - [0, 1, 1] -]) - -def create_rel_tile(pt: tuple[int, int]) -> Tile: - return pt[0] + 32 + ((pt[1] + 32) << 6) - -_REL_TILE_COORDS = [ - (x - 32, y - 32) for y in range(64) for x in range(64) +PIECE_NAMES = [ + "O1", "I2", "I3", "L3", "O4", "I4", "L4", + "Z4", "T4", "F5", "I5", "L5", "N5", "P5", + "T5", "U5", "V5", "W5", "X5", "Y5", "Z5", ] -# compute relative coordinates for the pieces -_PRP_WITH_REL_COORD: list[_PrpSet] = [0] * (64 * 64) -for _pt in _PIECE_ROTATION_POINTS: - for _tile in _pt.rel_tiles: - _PRP_WITH_REL_COORD[create_rel_tile(_tile)] |= _pt.as_set - -_PRP_WITH_ADJ_REL_COORD: list[_PrpSet] = [0] * (64 * 64) -for _pt in _PIECE_ROTATION_POINTS: - for _tile in _pt.rel_adjacent: - _PRP_WITH_ADJ_REL_COORD[create_rel_tile(_tile)] |= _pt.as_set +def tile_to_coords(tile: Tile) -> tuple[int, int]: + """Converts a valid tile to xy coordinates""" + ... -_PRP_REL_COORDS: list[Tile] = set() -for _i, _pt in enumerate(_PRP_WITH_REL_COORD): - if _pt: - _PRP_REL_COORDS.add(_i) -for _i, _pt in enumerate(_PRP_WITH_ADJ_REL_COORD): - if _pt: - _PRP_REL_COORDS.add(_i) -_PRP_REL_COORDS = list(_PRP_REL_COORDS) +def tile_in_bounds(tile: Tile) -> bool: + """Checks if a tile value is valid""" + ... -_PRP_WITH_PC_ID: list[_PrpSet] = [0] * N_PIECES -for _pt in _PIECE_ROTATION_POINTS: - _PRP_WITH_PC_ID[_pt.piece_id] |= _pt.as_set +def coords_to_tile(coords: tuple[int, int]) -> Tile: + """Converts valid xy coordinates to a tile""" + ... -# helpers for retrieving information about game pieces -def n_piece_contacts(piece: Piece) -> int: - return len(_PIECES[piece].rotations[0].rel_contacts) +def coords_in_bounds(coords: tuple[int, int]) -> bool: + """Checks if xy coordinates are valid""" + ... def n_piece_tiles(piece: Piece) -> int: - return len(_PIECES[piece].rotations[0].rel_tiles) + """Returns number of tiles in a piece""" + ... + +def n_piece_contacts(piece: Piece) -> int: + """Returns number of contacts in a piece""" + ... def n_piece_corners(piece: Piece) -> int: - return _PIECES[piece].rotations[0].n_corners + """Returns number of open corners a piece provides""" + ... def piece_tiles(piece: Piece, rotation: Rotation) -> list[Tile]: - return list(_PIECES[piece].rotations[rotation].tiles) - -def piece_tile_coords(piece: Piece, rotation: Rotation, contact: Tile=None) -> list[tuple[int, int]]: - if contact is None: - return list(_PIECES[piece].rotations[rotation].rel_tiles) - else: - return list(_PIECES[piece].rotations[rotation].prps[contact].rel_tiles) - -@dataclass -class _PlayerState: """ - Internal/private minimal representation of a player's state data - that will be pushed on a stack as the player takes game actions. - - Parameters - ---------- - prps : _PrpSet - The player's current set of piece-rotation-points - corners: dict[Tile, _PrpSet] - The player's current set of piece-rotation-points for each open corner tile - has_player : bool - Whether or not the player has made a move yet - score : int - The player's current score in the active game + Returns piece tiles relative to a rotation, where A01 is the bottom-left + coordinate of that rotations's bounding box """ + ... - prps: _PrpSet - corners: dict[Tile, _PrpSet] - has_played: bool - score: int - - def copy(self) -> '_PlayerState': - out = _PlayerState.__new__(_PlayerState) - out.prps = self.prps - out.corners = dict(self.corners) - out.has_played = self.has_played - out.score = self.score - - return out - -class _Player: +def piece_contacts(piece: Piece, rotation: Rotation) -> list[Tile]: """ - Internal/private representation of a player used - by a game Board instance. - - Parameters - ---------- - name : str - A name to identify the player by - id : Color - Which color the player is playing as, i.e. their turn order - borad : Board - A reference to the board the player is playing on + Returns piece contacts relative to a rotation, where A01 is the bottom-left + coordinate of that rotations's bounding box """ + ... - def __init__(self, name: str, id: Color, board: 'Board'): - self.name = name - self.id = id - self._prps = _PRP_SET_ALL - self.board = board - self._tiles = board._tiles - self.corners: dict[Tile, _PrpSet] = {} - self.has_played = False - self.score = 0 - self._state: list[_PlayerState] = [] - self._tgt = id + 1 +class Move: + """Represents a board move""" - # add the 4 initial corners of the board at game start - # since each player's first move has this rule exception - self.add_corner(A01) - self.add_corner(A20) - self.add_corner(T01) - self.add_corner(T20) - - def copy_current_state(self, board: 'Board') -> '_Player': - out = _Player.__new__(_Player) - out.name = self.name - out.id = self.id - out._prps = self._prps - out.board = board - out._tiles = board._tiles - out.corners = dict(self.corners) - out.has_played = self.has_played - out.score = self.score - out._state = [] - out._tgt = self._tgt - - return out + def __init__(piece: Piece, rotation: Rotation, contact: Tile, to_tile: Tile): + """Creates a move""" + ... @property - def can_play(self) -> bool: - return len(self.corners) > 0 - - def push_state(self) -> None: - prps = self._prps - corners = dict(self.corners) - - self._state.append(_PlayerState(prps, corners, self.has_played, self.score)) - - def pop_state(self) -> bool: - state = self._state.pop() - - self._prps = state.prps - self.corners = state.corners - self.has_played = state.has_played - self.score = state.score - - def remove_piece(self, piece_id: int) -> None: - # remove piece permutations from availability list - not_prps = ~_PRP_WITH_PC_ID[piece_id] - self._prps &= not_prps - - remove = [] - - # remove piece permutations from all open corners - for key, corner in self.corners.items(): - corner &= not_prps - if corner == 0: - remove.append(key) - else: - # corner is value, not reference - self.corners[key] = corner - - for r in remove: - del self.corners[r] + def piece(self) -> Piece: + """Piece used by the move""" + ... - def on_tiles_filled(self, tiles: list[Tile]) -> None: - # if an open corner was filled, no player can use it anymore - for tile in tiles: - self.corners.pop(tile, None) - - remove = [] - - # for each open corner, - # find all piece permutations that need one of the filled tiles - # and remove them from possible moves - for corner, prps in self.corners.items(): - cy, cx = tile_to_coords(corner) - invalid: _PrpSet = 0 - for tile in tiles: - c = TILE_COORDS[tile] - rel = create_rel_tile((c[0] - cy, c[1] - cx)) - invalid |= _PRP_WITH_REL_COORD[rel] - prps &= ~invalid - if prps == 0: - remove.append(corner) - else: - self.corners[corner] = prps - - for r in remove: - del self.corners[r] - - def add_corner(self, tile: Tile) -> None: - if tile in self.corners: - return - - bad: _PrpSet = ~_PRP_SET_ALL - - tgt = self._tgt - x, y = tile_to_coords(tile) - - for rel in _PRP_REL_COORDS: - pt = _REL_TILE_COORDS[rel] - pt = (pt[0] + x, pt[1] + y) - t = pt[0] + pt[1] * 20 - ib = in_bounds(pt) - - if ib: - if col := self._tiles[t]: - bad |= _PRP_WITH_REL_COORD[rel] - if not (col - tgt): - bad |= _PRP_WITH_ADJ_REL_COORD[rel] - else: - bad |= _PRP_WITH_REL_COORD[rel] - - prps = self._prps & ~bad - if prps: - self.corners[tile] = prps - -class Move: - """ - Public representation of a Move to be played on a game board. - - Parameters - ---------- - piece : Piece - Which of the 21 basic piece shapes is used in this Move - rotation : Rotation - Which of the 8 rotations (4 normal, 4 flipped) is used in this Move - contact : Tile - Which square on the piece is being placed at an open corner in this Move - to_tile : Tile - Which square on the board the contact is being placed at by this Move - """ - - def __init__(self, piece: Piece, rotation: Rotation, contact: Tile, to_tile: Tile): - self.piece = piece - self.rotation = rotation - self.contact = contact - self.to_tile = to_tile - - def __str__(self): - return _PIECES[self.piece].name + \ - ROTATION_NAMES[self.rotation] + \ - "-" + \ - TILE_NAMES[self.contact] + \ - TILE_NAMES[self.to_tile] - - def __hash__(self): - # adds support for using Move objects in sets - return self.piece * 2659 + \ - self.rotation * 5393 + \ - self.contact * 571 + \ - self.to_tile * 1607 - - def is_equal(self, value: 'Move') -> bool: - return \ - self.piece == value.piece and \ - self.rotation == value.rotation and \ - self.contact == value.contact and \ - self.to_tile == value.to_tile - - def __eq__(self, value: object) -> bool: - if isinstance(value, Move): - return self.is_equal(value) - else: - return False - - def to_unique(self) -> 'Move': - """ - If a Move has several rotations/flips that result in the same move - this function helps simplify them to one version. - """ - - return Move( - self.piece, - _PIECES[self.piece].true_rot[self.rotation], - self.contact, - self.to_tile - ) - -@dataclass -class _BoardState: - """ - Internal/private minimal representation of a board's state data - that will be pushed on a stack as actions are taken in the game. + @property + def rotation(self) -> Rotation: + """Rotation used by the move""" + ... - Parameters - ---------- - cur_player : int - Which player's turn it is in the game - tiles : list[Tile] - The list of all Tile objects on the board - """ + @property + def contact(self) -> Tile: + """Contact used by the move""" + ... - cur_player: int - tiles: list[Tile] + @property + def to_tile(self) -> Tile: + """Tile that the move's contact will be placed at""" + ... class Board: - """ - Public representation of a game Board which is how - players or bots interface with the game. - - Parameters - ---------- - n_players : int - The number of players participating in the game on this board - """ - - def __init__(self, n_players: int): - if n_players < 1 or n_players > 4: - raise Exception("Number of players must be between 1 and 4") + """Represents a tilewe board""" - self._state: list[_BoardState] = [] - self._tiles = np.zeros((400,), dtype=np.uint8) - self._n_players = n_players - self._players: list[_Player] = [] + def __init__(self, n_players: int=4): + """Creates a board""" + ... - chars = [ - 'B', - 'Y', - 'R', - 'G' - ] - for i in range(n_players): - self._players.append(_Player(chars[i], i, self)) - - self.current_player: Color = BLUE - self.finished = False - self.ply = 0 - self.moves: list[Move] = [] + @property + def ply(self) -> int: + """Current ply, where each move increments the ply""" + ... - def copy_current_state(self) -> 'Board': - out = Board.__new__(Board) - out._state = [] - out._tiles = np.copy(self._tiles) - out._n_players = self._n_players - out._players = [p.copy_current_state(out) for p in self._players] - out.current_player = self.current_player - out.finished = self.finished - out.ply = self.ply - out.moves = [] + @property + def current_player(self) -> Color: + """Color of the current player""" + ... - return out + @property + def cur_player(self) -> Color: + """Color of the current player""" + ... @property def n_players(self) -> int: - return self._n_players + """Number of players""" + ... @property def scores(self) -> list[int]: - return [p.score for p in self._players] + """List of each player's score""" + ... @property def winners(self) -> list[int]: - if not self.finished: - return None - - best = [0] - for i in range(1, len(self._players)): - if self._players[i].score == self._players[best[0]].score: - best.append(i) - elif self._players[i].score > self._players[best[0]].score: - best = [i] - return best - - def player_corners(self, player: Color) -> list[Tile]: - return list(self._players[player].corners.keys()) - - def n_player_corners(self, player: Color) -> int: - return len(self._players[player].corners) - - def player_score(self, player: Color) -> int: - return self._players[player].score - - def can_play(self, player: Color) -> bool: - return self._players[player].can_play - - def _remaining_piece_set(self, player: Color) -> set[Piece]: - pieces = set() - prps = self._players[player]._prps - - while prps: - # get least significant bit - prp = (prps & -prps).bit_length() - 1 - # remove it so the next LSB is another PRP - prps ^= 1 << prp - - pieces.add(_PIECE_ROTATION_POINTS[prp].piece_id) - - return pieces - - def remaining_pieces(self, player: Color) -> list[Piece]: - return list(self._remaining_piece_set(player)) - - def n_remaining_pieces(self, player: Color) -> int: - return len(self._remaining_piece_set(player)) - - def color_at(self, tile: Tile) -> Color: - return Color(self._tiles[tile] - 1) - - def _is_legal(self, prp_id: int, tile: Tile, player: Color=None) -> bool: - if player is None: - player = self.current_player - - player = self._players[player] - prps = player.corners.get(tile, 0) + """List of current winners""" + ... - return (prps & (1 << prp_id)) != 0 - - def is_legal(self, move: Move, for_player: Color=None) -> bool: - player = self._players[self.current_player if for_player is None else for_player] - - # target tile must be empty - if move.to_tile is None or self.color_at(move.to_tile) != NO_COLOR: - return False - - # piece must be real - if move.piece is None or move.piece >= len(_PIECES) or move.piece < 0: - return False - pc = _PIECES[move.piece] - - # rotation must be real - if move.rotation is None or move.rotation >= len(ROTATIONS) or move.rotation < 0: - return False - pc_rot = pc.rotations[move.rotation] - - # piece rotation must have the contact - prp = pc_rot.prps.get(move.contact, None) - if prp is None: - return False - - # available permutations at the requested tile - prps = player.corners.get(move.to_tile, None) - if prps is None: - return False - - # permutation must fit at the corner square - return (prps & prp.as_set) != 0 - - def n_legal_moves(self, unique: bool=True, for_player: Color=None) -> int: - player = self._players[self.current_player if for_player is None else for_player] - total = 0 - - if unique: - for prps in player.corners.values(): - total += prps.bit_count() - else: - for prps in player.corners.values(): - while prps: - # get least significant bit - prp_id = (prps & -prps).bit_length() - 1 - # remove it so the next LSB is another PRP - prps ^= 1 << prp_id - - prp = _PIECE_ROTATION_POINTS[prp_id] - - # include all rotations that are equivalent to this PRP - total += len(prp.piece.true_rot_for[prp.rotation.rotation]) - - return total - - def generate_legal_moves(self, unique: bool=True, for_player: Color=None) -> list[Move]: - moves: list[Move] = [] - player = self._players[self.current_player if for_player is None else for_player] - - # duplicate for loop so that we don't check the if statement for every permutation - if unique: - for to_sq, prps in player.corners.items(): - while prps: - # get least significant bit - prp_id = (prps & -prps).bit_length() - 1 - # remove it so the next LSB is another PRP - prps ^= 1 << prp_id - - prp = _PIECE_ROTATION_POINTS[prp_id] - - moves.append(Move( - prp.piece.id, - prp.rotation.rotation, - prp.contact, - to_sq - )) - else: - for to_sq, prps in player.corners.items(): - while prps: - # get least significant bit - prp_id = (prps & -prps).bit_length() - 1 - # remove it so the next LSB is another PRP - prps ^= 1 << prp_id - - prp = _PIECE_ROTATION_POINTS[prp_id] - - # include permutations with non-unique rotations/flips - for rot in prp.piece.true_rot_for[prp.rotation.rotation]: - moves.append(Move( - prp.piece.id, - rot, - prp.contact, - to_sq - )) - - return moves - - def push_null(self) -> None: - self._state.append(_BoardState(self._incr_player(), None)) + @property + def finished(self) -> bool: + """Whether the game is done""" + ... - def pop_null(self) -> None: - state = self._state.pop() - self.current_player = state.cur_player - self.finished = False - self.ply -= 1 + def generate_legal_moves(self, for_player: Color=None) -> list[Move]: + """Generates moves""" + ... def push(self, move: Move) -> None: - """ - Legality is assumed to be true. - """ - prp = _PIECES[move.piece].rotations[move.rotation].prps[move.contact] - self._push_prp(move, prp, move.to_tile) + """Play a move""" + ... def pop(self) -> None: - state = self._state.pop() - self.moves.pop() + """Undo a move""" + ... - # take back piece - for tile in state.tiles: - self._tiles[tile] = 0 + def n_legal_moves(self, for_player: Color=None) -> int: + """Gets total number of legal moves for a player""" + ... - self.current_player = state.cur_player - self.finished = False - self.ply -= 1 + def n_remaining_pieces(self, for_player: Color=None) -> int: + """Gets total number of pieces remaining for a player""" + ... - # player state is stored per player - for player in self._players: - player.pop_state() + def remaining_pieces(self, for_player: Color=None) -> list[Piece]: + """Gets a list of pieces remaining for a player""" + ... - def _incr_player(self) -> Color: - self.ply += 1 - cur_turn = self.current_player - while True: - self.current_player += 1 - if self.current_player >= len(self._players): - self.current_player = 0 - if self._players[self.current_player].can_play: - break + def n_player_corners(self, for_player: Color=None) -> int: + """Gets total number of open corners for a player""" + ... - # looped all the way back to same player - # no player can play, game is done - if cur_turn == self.current_player: - self.finished = True - break - - # return last player - return cur_turn + def player_corners(self, for_player: Color=None) -> list[Tile]: + """Gets a list of the open corners for a player""" + ... - def _push_prp(self, move: Move, prp: _PieceRotationPoint, tile: Tile) -> None: - self.moves.append(move) - - player = self._players[self.current_player] - - for p in self._players: - p.push_state() - - # absolute position of tiles - tx, ty = TILE_COORDS[tile] - tiles = [coords_to_tile((t[0] + tx, t[1] + ty)) for t in prp.rel_tiles] - corners = [(t[0] + tx, t[1] + ty) for t in prp.rel_corners] - adj = [(t[0] + tx, t[1] + ty) for t in prp.rel_adjacent] - - corners = [coords_to_tile(c) for c in corners if in_bounds(c)] - adj = [coords_to_tile(c) for c in adj if in_bounds(c)] - - for abs_tile in corners: - player.add_corner(abs_tile) - - for abs_tile in tiles: - self._tiles[abs_tile] = self.current_player + 1 - - for p in self._players: - p.on_tiles_filled(tiles) - - # player can't place tiles adjacent to their own color - player.on_tiles_filled(adj) - player.remove_piece(prp.piece_id) - - if not player.has_played: - player.corners.pop(A01, None) - player.corners.pop(A20, None) - player.corners.pop(T01, None) - player.corners.pop(T20, None) - - player.has_played = True - player.score += len(prp.rel_tiles) - - # inc turn and make sure player can move - cur_turn = self._incr_player() - - self._state.append(_BoardState(cur_turn, tiles)) - - def __str__(self): - out = "" + def player_score(self, for_player: Color=None) -> int: + """Gets the score of a player""" + ... - board = self._tiles.reshape((20, 20))[::-1] - - chars = None - if print_color: - chars = [ - '.', - '\033[94mB\033[0m', - '\033[93mY\033[0m', - '\033[91mR\033[0m', - '\033[92mG\033[0m' - ] - else: - chars = [ - '.', - 'B', - 'Y', - 'R', - 'G' - ] - - for y in range(20): - for x in range(20): - out += f"{chars[board[y,x]]} " - out += "\n" + def can_play(self, for_player: Color=None) -> bool: + """Whether a player has remaining moves""" + ... - for player in self._players: - out += f"{player.name}: {int(player.score)} " - - pcs = [_PIECES[pc] for pc in self._remaining_piece_set(player.id)] - - if len(pcs) > 0: - pcs = sorted(pcs, key=lambda x: x.id) - - out += "( " - for pc in pcs: - out += pc.name + " " - out += ")\n" - else: - out += "( )\n" - - out += f"Finished: {self.finished}" - if self.finished: - out += "\nWinner: " - for p in self.winners: - out += f"{self._players[p].name} " - else: - out += f"\nTurn: {self._players[self.current_player].name}" + def is_legal(self, move: Move, for_player: Color=None) -> bool: + """Whether a move is legal for a player""" + ... - return out +from ctilewe import * # noqa: E402, F401, F403 diff --git a/tilewe/elo.py b/tilewe/elo.py index 6b9e771..6aaa20d 100644 --- a/tilewe/elo.py +++ b/tilewe/elo.py @@ -1,10 +1,76 @@ import math +_c_k = { 0: 1.0 } + +def c_k(k: int) -> float: + """ + Computes a memoized c_k value, used for erfinv. + + References + ---------- + https://en.wikipedia.org/wiki/Error_function#Inverse_functions + """ + + if k < 0: + raise Exception("k cannot be less than 0") + if k in _c_k: + return _c_k[k] + + total = 0 + for m in range(k): + total += c_k(m) * c_k(k - 1 - m) / ((m + 1) * (2 * m + 1)) + + _c_k[k] = total + return total + +def erfinv(x: float) -> float: + """ + Inverse error function. Only defined for -1 < x < 1. + + Parameters + ---------- + x : float + Desired output of (non-inverse) error function. + + Returns + ------- + e_x : float + Input to the error function that results in an output of x + + References + ---------- + https://en.wikipedia.org/wiki/Error_function#Inverse_functions + """ + if x <= -1: + return math.nan + elif x >= 1: + return math.nan + + last = 0.0 + out = 0.0 + k = 0 + r_pi_2 = math.sqrt(math.pi) * 0.5 + + while True: + out += c_k(k) / (2 * k + 1) * ((r_pi_2 * x) ** (2 * k + 1)) + + if abs(out - last) < 0.00001: + break + + last = out + k += 1 + + return out + def elo_win_probability(elo1: float, elo2: float, C: int=400): """ Returns the probability of player with elo1 winning against player with elo2. + References + ---------- + https://en.wikipedia.org/wiki/Elo_rating_system + Parameters ---------- elo1 : float @@ -18,10 +84,6 @@ def elo_win_probability(elo1: float, elo2: float, C: int=400): ------- probability : float The probability that player 1 will win against player 2, range 0 to 1 - - See Also - -------- - https://en.wikipedia.org/wiki/Elo_rating_system """ # raw @@ -37,6 +99,10 @@ def compute_elo_adjustment_2(elo1: float, elo2: float, outcome: float, K: int = Returns the adjustment to the elo of player with elo1 based on the outcome of the match against player with elo2. + References + ---------- + https://en.wikipedia.org/wiki/Elo_rating_system + Parameters ---------- elo1 : float @@ -54,9 +120,9 @@ def compute_elo_adjustment_2(elo1: float, elo2: float, outcome: float, K: int = The change in player 1's elo based on the outcome of the match """ - p1_win_probability = elo_win_probability(elo1, elo2) - new_elo1 = elo1 + K * (outcome - p1_win_probability) - delta_elo = new_elo1 - elo1 + p1_win_probability: float = elo_win_probability(elo1, elo2) + new_elo1: float = elo1 + K * (outcome - p1_win_probability) + delta_elo: float = new_elo1 - elo1 return delta_elo def compute_elo_adjustment_n(elos: list[float], scores: list[int], K: int = 32): @@ -80,10 +146,10 @@ def compute_elo_adjustment_n(elos: list[float], scores: list[int], K: int = 32): The change in each player's elo based on the outcome of the match """ - player_count = len(elos) - mod_K = K / (player_count - 1) - delta_elos = [ 0 for _ in range(player_count)] - winning_score = max(scores) + player_count: int = len(elos) + mod_K: float = K / (player_count - 1) + delta_elos: list[float] = [0] * player_count + winning_score: int = max(scores) for player1 in range(player_count): for player2 in range(player_count): @@ -91,11 +157,163 @@ def compute_elo_adjustment_n(elos: list[float], scores: list[int], K: int = 32): continue # any losers lose to winners and draw with other losers / any winners win over losers and draw with other winners - player1_win = scores[player1] == winning_score - player2_win = scores[player2] == winning_score - outcome = 0 if player2_win and not player1_win else 1 if player1_win and not player2_win else 0.5 + player1_win: bool = scores[player1] == winning_score + player2_win: bool = scores[player2] == winning_score + outcome: float = 0 if player2_win and not player1_win else 1 if player1_win and not player2_win else 0.5 - delta_elo = compute_elo_adjustment_2(elos[player1], elos[player2], outcome, mod_K) + delta_elo: float = compute_elo_adjustment_2(elos[player1], elos[player2], outcome, mod_K) delta_elos[player1] += delta_elo return delta_elos + +def compute_inverse_error(e: float) -> float: + """ + Returns the inverse error of e. + + Parameters + ---------- + e : float + The input value + + Returns + ------- + inverse_error : float + The inverse error of e + """ + + return math.sqrt(2) * erfinv(2 * e - 1) + +def compute_elo_error_margin(wins: int, draws: int, losses: int, confidence: float=0.95, C: int=400) -> float: + """ + Returns the Elo error margin for a set of results. + + References + ---------- + https://en.wikipedia.org/wiki/Elo_rating_system + https://stackoverflow.com/a/31266328 + https://github.com/cutechess + + Parameters + ---------- + wins : int + The number of wins + draws : int + The number of draws + losses : int + The number of losses + confidence : float + The confidence level, usually 0.95 + C : int + The elo relativity constant, usually 400 + + Returns + ------- + error_margin : float + The Elo error margin + """ + + if confidence <= 0 or confidence >= 1: + raise ValueError(f"confidence must be between 0 and 1 (exclusive), got {confidence}") + if C < 1: + raise ValueError(f"C must be greater than or equal to 1, got {C}") + + total: int = wins + draws + losses + if total == 0: + # no games, no confidence + return math.inf + + win_rate: float = wins / total + draw_rate: float = draws / total + loss_rate: float = losses / total + win_draw_factor: float = win_rate + (draw_rate / 2) + + win_deviation: float = win_rate * math.pow(1 - win_draw_factor, 2) + draw_deviation: float = draw_rate * math.pow(0.5 - win_draw_factor, 2) + loss_deviation: float = loss_rate * math.pow(0 - win_draw_factor, 2) + total_deviation: float = math.sqrt(win_deviation + draw_deviation + loss_deviation) / math.sqrt(total) + + minimum: float = win_draw_factor + compute_inverse_error(1 - confidence) * total_deviation + maximum: float = win_draw_factor + compute_inverse_error(confidence) * total_deviation + + if minimum == 0 or maximum == 0: + # cannot compute confidence due to division by zero + return math.inf + + min_recip: float = 1 / minimum + max_recip: float = 1 / maximum + if min_recip <= 1 or max_recip <= 1: + # cannot compute confidence due to zero/negative log + return math.inf + + min_log: float = -C * math.log10(min_recip - 1) + max_log: float = -C * math.log10(max_recip - 1) + + return abs((max_log - min_log) / 2) + +def compute_estimated_elo(n: int, ppg: list[list[int]], rpg: list[list[float]], mean: float=0) -> list[float]: + """ + Computes Elo given a list of game players and results. Results should be: + - 1 for a win + - 0 for a loss + - 1/2 for a draw + + Parameters + ---------- + n : int + Total number of players + ppg : list[list[int]] + List of player indices for each game + rpg : list[list[float]] + List of player results for each game + mean : float + What the average rating should be + + Returns + ------- + Estimated Elo rating for all players + """ + + def expected_elo(results: list[float]) -> float: + if len(results) == 0: + return math.nan + + wr = sum(results) / len(results) + + if wr >= 1: + return math.inf + elif wr <= 0: + return -math.inf + else: + # calculates total difference between the player and one virtual opponent + # the opponent will also have their elo adjusted so only return half of it + return -400 * math.log10(1.0 / wr - 1) / 2 + + elos = [] + games = len(ppg) + + # results per player + rpp = [[] for _ in range(n)] + + # game indices per player + gpp = [[] for _ in range(n)] + + for p in range(n): + for g in range(games): + if p in ppg[g]: + # player played in game g + gpp[p].append(g) + # player had result in game g + rpp[p].append(rpg[g][ppg[g].index(p)]) + + for p in range(n): + # update elo + elos.append(expected_elo(rpp[p])) + + # adjust ratings to prevent drift + finite_e = [e for e in elos if math.isfinite(e)] + if len(finite_e): + avg = sum(finite_e) / len(finite_e) + for i in range(n): + elos[i] += mean - avg + + return elos diff --git a/tilewe/engine.py b/tilewe/engine.py index e704c7a..97a773f 100644 --- a/tilewe/engine.py +++ b/tilewe/engine.py @@ -9,12 +9,19 @@ class Engine: Currently requires overriding the `on_search` function which must return one legal move within the given time control. + An Engine's self-estimated Elo should be relative to a 0-point scale, + not a 1500-point scale. For example, if you think your engine would + have a 50% win rate against an "average" opponent, you should leave it + at None/0.0. If you think your engine would have a 75% win rate against + an "average" opponent, you should set it to 200.0. + For extension examples, see the Sample Engines below. For construction examples, see the tilewe.tournament.Tournament class. """ - def __init__(self, name: str): + def __init__(self, name: str, estimated_elo: float=None): self.name = name + self.estimated_elo = estimated_elo self.seconds = 0 self.end_at = time.time() @@ -65,11 +72,11 @@ class RandomEngine(Engine): Pretty bad, but makes moves really fast. """ - def __init__(self, name: str="Random"): - super().__init__(name) + def __init__(self, name: str="Random", estimated_elo: float=None): + super().__init__(name, -100.0 if estimated_elo is None else estimated_elo) def on_search(self, board: tilewe.Board, _seconds: float) -> tilewe.Move: - return random.choice(board.generate_legal_moves(unique=True)) + return random.choice(board.generate_legal_moves()) class MostOpenCornersEngine(Engine): """ @@ -79,11 +86,11 @@ class MostOpenCornersEngine(Engine): Fairly weak but does result in decent board coverage behavior. """ - def __init__(self, name: str="MostOpenCorners"): - super().__init__(name) + def __init__(self, name: str="MostOpenCorners", estimated_elo: float=None): + super().__init__(name, 15.0 if estimated_elo is None else estimated_elo) def on_search(self, board: tilewe.Board, _seconds: float) -> tilewe.Move: - moves = board.generate_legal_moves(unique=True) + moves = board.generate_legal_moves() random.shuffle(moves) player = board.current_player @@ -106,17 +113,20 @@ class LargestPieceEngine(Engine): ties, it's effectively a greedy form of RandomEngine. """ - def __init__(self, name: str="LargestPiece"): - super().__init__(name) + def __init__(self, name: str="LargestPiece", estimated_elo: float=None): + super().__init__(name, 30.0 if estimated_elo is None else estimated_elo) def on_search(self, board: tilewe.Board, _seconds: float) -> tilewe.Move: - moves = board.generate_legal_moves(unique=True) + moves = board.generate_legal_moves() random.shuffle(moves) - best = max(moves, key=lambda m: - tilewe.n_piece_tiles(m.piece) * 100 + - tilewe.n_piece_corners(m.piece) * 10 + - tilewe.n_piece_contacts(m.piece)) + def score(m: tilewe.Move): + pc = m.piece + return tilewe.n_piece_tiles(pc) * 100 + \ + tilewe.n_piece_corners(pc) * 10 + \ + tilewe.n_piece_contacts(pc) + + best = max(moves, key=score) return best @@ -131,20 +141,21 @@ class MaximizeMoveDifferenceEngine(Engine): getting access to an open area on the board, etc. """ - def __init__(self, name: str="MaximizeMoveDifference"): - super().__init__(name) + def __init__(self, name: str="MaximizeMoveDifference", estimated_elo: float=None): + super().__init__(name, 50.0 if estimated_elo is None else estimated_elo) def on_search(self, board: tilewe.Board, _seconds: float) -> tilewe.Move: - moves = board.generate_legal_moves(unique=True) + moves = board.generate_legal_moves() random.shuffle(moves) player = board.current_player + N = board.n_players def eval_after_move(m: tilewe.Move) -> int: with MoveExecutor(board, m): total = 0 - for color in range(board.n_players): - n_moves = board.n_legal_moves(unique=True, for_player=color) + for color in range(N): + n_moves = board.n_legal_moves(for_player=color) total += n_moves * (1 if color == player else -1) return total @@ -213,13 +224,22 @@ class TileWeightEngine(Engine): 'turtle': TURTLE_WEIGHTS } - def __init__(self, name: str="TileWeight", weight_map: str='wall_crawl', custom_weights: list[int | float]=None): + weight_elos = { + 'wall_crawl': -10.0, + 'turtle': -40.0 + } + + def __init__(self, + name: str="TileWeight", + weight_map: str='wall_crawl', + custom_weights: list[int | float]=None, + estimated_elo: float=None): """ Current `weight_map` built-in options are 'wall_crawl' and 'turtle' Can optionally provide a custom set of weights instead """ - super().__init__(name) + est_elo: float = 0.0 if estimated_elo is None else estimated_elo if custom_weights is not None: if len(custom_weights) != 20 * 20: @@ -230,22 +250,25 @@ def __init__(self, name: str="TileWeight", weight_map: str='wall_crawl', custom_ if weight_map not in self.weight_maps: raise Exception("TileWeightEngine given invalid weight_map choice") self.weights = self.weight_maps[weight_map] + if estimated_elo is None: + est_elo = self.weight_elos[weight_map] + + super().__init__(name, estimated_elo=est_elo) def on_search(self, board: tilewe.Board, _seconds: float) -> tilewe.Move: cur_player = board.current_player def evaluate_move_weight(move: tilewe.Move) -> float: - total: float = 0.0 + total: float = 0 - to_coords = tilewe.tile_to_coords(move.to_tile) - for coords in tilewe.piece_tile_coords(move.piece, move.rotation, move.contact): - coords = (coords[0] + to_coords[0], coords[1] + to_coords[1]) - total += self.weights[tilewe.coords_to_tile(coords)] + offset = move.to_tile - move.contact + for tile in tilewe.piece_tiles(move.piece, move.rotation): + total += self.weights[tile + offset] return total - moves = board.generate_legal_moves(unique=True) + moves = board.generate_legal_moves() random.shuffle(moves) if board.ply < board.n_players: diff --git a/tilewe/setup.py b/tilewe/setup.py deleted file mode 100644 index 51a6afa..0000000 --- a/tilewe/setup.py +++ /dev/null @@ -1,3 +0,0 @@ -from setuptools import setup, find_packages - -setup(name='tilewe', version='0.0.1', packages=find_packages()) diff --git a/tilewe/src/ctilewemodule.c b/tilewe/src/ctilewemodule.c new file mode 100644 index 0000000..d47f784 --- /dev/null +++ b/tilewe/src/ctilewemodule.c @@ -0,0 +1,1014 @@ +#define PY_SSIZE_T_CLEAN +#include + +#include + +#include "Tilewe/Tilewe.h" + +typedef struct MoveObject MoveObject; + +struct MoveObject +{ + PyObject_HEAD + Tw_Move Move; +}; + +static int Move_init(MoveObject* self, PyObject* args, PyObject* kwds) +{ + static const char* kwlist[] = + { + "piece", + "rotation", + "contact", + "to_tile", + NULL + }; + + unsigned pc, rot, con, tile; + if (!PyArg_ParseTupleAndKeywords(args, kwds, "IIII", kwlist, &pc, &rot, &con, &tile)) + { + PyErr_SetString(PyExc_AttributeError, "all parameters must be used"); + return -1; + } + + self->Move = Tw_MakeMove_Safe(pc, rot, con, tile); + + if (self->Move == Tw_NoMove) + { + PyErr_SetString(PyExc_AttributeError, "move must have a valid piece, rotation, contact, and tile combination"); + return -1; + } + + return 0; +} + +static PyObject* Move_getstate(MoveObject* self, PyObject* Py_UNUSED(ignored)) +{ + return Py_BuildValue("I", (unsigned) self->Move); +} + +static PyObject* Move_setstate(MoveObject* self, PyObject* state) +{ + if (!PyLong_CheckExact(state)) + { + PyErr_SetString(PyExc_ValueError, "Pickled object is not an int."); + return NULL; + } + + Tw_Move move = (Tw_Move) PyLong_AsUnsignedLong(state); + + if (move == Tw_NoMove || move != Tw_MakeMove_Safe( + Tw_Move_Pc(move), + Tw_Move_Rot(move), + Tw_Move_Con(move), + Tw_Move_ToTile(move) + )) + { + PyErr_SetString(PyExc_ValueError, "Pickled object is not a valid move."); + return NULL; + } + + Py_RETURN_NONE; +} + +static PyObject* Move_richcompare(MoveObject* self, PyObject* obj, int op); + +static PyObject* Move_hash(MoveObject* self, PyObject* Py_UNUSED(ignored)) +{ + return PyLong_FromUnsignedLong(self->Move); +} + +static PyObject* Move_str(MoveObject* self, PyObject* Py_UNUSED(ignored)) +{ + char buf[32]; + snprintf(buf, 32, "%s%s-%s%s", + Tw_Pc_Str(Tw_Move_Pc(self->Move)), + Tw_Rot_Str(Tw_Move_Rot(self->Move)), + Tw_Tile_Str(Tw_Move_Con(self->Move)), + Tw_Tile_Str(Tw_Move_ToTile(self->Move)) + ); + + return PyUnicode_FromString(buf); +} + +static PyObject* Move_Piece(MoveObject* self, void* closure) +{ + return PyLong_FromLong(Tw_Move_Pc(self->Move)); +} + +static PyObject* Move_Rotation(MoveObject* self, void* closure) +{ + return PyLong_FromLong(Tw_Move_Rot(self->Move)); +} + +static PyObject* Move_Contact(MoveObject* self, void* closure) +{ + return PyLong_FromLong(Tw_Move_Con(self->Move)); +} + +static PyObject* Move_Tile(MoveObject* self, void* closure) +{ + return PyLong_FromLong(Tw_Move_ToTile(self->Move)); +} + +static PyGetSetDef Move_getsets[] = +{ + { "piece", Move_Piece, NULL, "Gets the move piece", NULL }, + { "rotation", Move_Rotation, NULL, "Gets the move rotation", NULL }, + { "contact", Move_Contact, NULL, "Gets the move contact tile", NULL }, + { "to_tile", Move_Tile, NULL, "Gets the move open corner", NULL }, + { NULL } +}; + +static PyMethodDef Move_methods[] = +{ + { "__getstate__", Move_getstate, METH_NOARGS, "Pickle the move" }, + { "__setstate__", Move_setstate, METH_O, "Un-pickle the move" }, + { NULL } +}; + +static PyTypeObject MoveType = +{ + .ob_base = PyVarObject_HEAD_INIT(NULL, 0) + .tp_name = "ctilewe.Move", + .tp_doc = PyDoc_STR("Representation of a Move."), + .tp_basicsize = sizeof(MoveObject), + .tp_itemsize = 0, + .tp_flags = Py_TPFLAGS_DEFAULT, + .tp_new = PyType_GenericNew, + .tp_init = Move_init, + .tp_str = Move_str, + .tp_repr = Move_str, + .tp_hash = Move_hash, + .tp_richcompare = Move_richcompare, + .tp_methods = Move_methods, + .tp_getset = Move_getsets +}; + +static PyObject* Move_richcompare(MoveObject* self, PyObject* obj, int op) +{ + if (!PyObject_TypeCheck(obj, &MoveType)) + { + return PyBool_FromLong(false); + } + + PyObject* out = NULL; + + switch (op) + { + case Py_LT: + out = self->Move < ((MoveObject*) obj)->Move ? Py_True : Py_False; + break; + case Py_LE: + out = self->Move <= ((MoveObject*) obj)->Move ? Py_True : Py_False; + break; + case Py_EQ: + out = self->Move == ((MoveObject*) obj)->Move ? Py_True : Py_False; + break; + case Py_NE: + out = self->Move != ((MoveObject*) obj)->Move ? Py_True : Py_False; + break; + case Py_GT: + out = self->Move > ((MoveObject*) obj)->Move ? Py_True : Py_False; + break; + case Py_GE: + out = self->Move >= ((MoveObject*) obj)->Move ? Py_True : Py_False; + break; + } + + Py_XINCREF(out); + return out; +} + +typedef struct BoardObject BoardObject; + +struct BoardObject +{ + PyObject_HEAD + Tw_Board Board; +}; + +static int Board_init(BoardObject* self, PyObject* args, PyObject* kwds) +{ + static const char* kwlist[] = { "n_players", NULL }; + + int numPlayers = 4; + if (!PyArg_ParseTupleAndKeywords(args, kwds, "|i", kwlist, &numPlayers)) + { + return -1; + } + + if (numPlayers < 1 || numPlayers > 4) + { + PyErr_SetString(PyExc_AttributeError, "n_players must be between 1 and 4"); + return -1; + } + + Tw_InitBoard(&self->Board, numPlayers); + return 0; +} + +static PyObject* Board_CurrentPlayer(BoardObject* self, void* closure) +{ + return PyLong_FromLong(self->Board.CurTurn); +} + +static PyObject* Board_NumPlayers(BoardObject* self, void* closure) +{ + return PyLong_FromLong(self->Board.NumPlayers); +} + +static PyObject* Board_Finished(BoardObject* self, void* closure) +{ + return PyBool_FromLong(self->Board.Finished); +} + +static PyObject* Board_Ply(BoardObject* self, void* closure) +{ + return PyLong_FromLong(self->Board.Ply); +} + +static PyObject* Board_Moves(BoardObject* self, void* closure) +{ + PyObject* list = PyList_New((unsigned) self->Board.Ply); + + for (int i = 0; i < self->Board.Ply; i++) + { + MoveObject* mv = PyObject_New(MoveObject, &MoveType); + mv->Move = self->Board.History[i].Move; + + PyList_SetItem(list, i, mv); + } + + return list; +} + +static PyObject* Board_Scores(BoardObject* self, void* closure) +{ + PyObject* list = PyList_New((unsigned) self->Board.NumPlayers); + + for (int i = 0; i < self->Board.NumPlayers; i++) + { + PyList_SetItem( + list, + i, + PyLong_FromLong(self->Board.Players[i].Score) + ); + } + + return list; +} + +static PyObject* Board_Winners(BoardObject* self, void* closure) +{ + PyObject* list = PyList_New(0); + + int best = -1; + for (int i = 0; i < self->Board.NumPlayers; i++) + { + int score = self->Board.Players[i].Score; + + if (score > best) + { + PyList_SetSlice(list, 0, PyList_Size(list), NULL); + best = score; + } + + if (score == best) + { + PyList_Append(list, PyLong_FromLong(i)); + } + } + + return list; +} + +static PyObject* Board_GenMoves(BoardObject* self, PyObject* Py_UNUSED(ignored)) +{ + Tw_MoveList moves; + Tw_InitMoveList(&moves); + Tw_Board_GenMoves(&self->Board, &moves); + + PyObject* list = PyList_New(moves.Count); + + for (int i = 0; i < moves.Count; i++) + { + MoveObject* mv = PyObject_New(MoveObject, &MoveType); + mv->Move = moves.Elements[i]; + + PyList_SetItem(list, i, mv); + } + + return list; +} + +static PyObject* Board_Push(BoardObject* self, PyObject* args, PyObject* kwds) +{ + static const char* kwlist[] = + { + "move", + NULL + }; + + MoveObject* move; + + if (!PyArg_ParseTupleAndKeywords(args, kwds, "O", kwlist, &move)) + { + return NULL; + } + + if (!PyObject_TypeCheck(move, &MoveType)) + { + PyErr_SetString(PyExc_AttributeError, "Must be a move"); + return NULL; + } + + Tw_Board_Push(&self->Board, move->Move); + + Py_RETURN_NONE; +} + +static PyObject* Board_ColorAt(BoardObject* self, PyObject* args, PyObject* kwds) +{ + static const char* kwlist[] = + { + "tile", + NULL + }; + + unsigned long tile; + + if (!PyArg_ParseTupleAndKeywords(args, kwds, "I", kwlist, &tile)) + { + return NULL; + } + + return PyLong_FromUnsignedLong(Tw_Board_ColorAt(&self->Board, tile)); +} + +static bool ForPlayerArgHandler(BoardObject* self, PyObject* args, PyObject* kwds, int* player) +{ + static const char* kwlist[] = + { + "for_player", + NULL + }; + + *player = Tw_Color_None; + + if (!PyArg_ParseTupleAndKeywords(args, kwds, "|i", kwlist, player)) + { + *player = -1; + return false; + } + + if (*player == Tw_Color_None) + { + *player = self->Board.CurTurn; + } + + if (*player < 0 || *player >= self->Board.NumPlayers) + { + *player = -1; + PyErr_SetString(PyExc_AttributeError, "for_player must be valid or None"); + return false; + } + + return true; +} + +// TODO use better way that doesn't duplicate so much code +static bool ForPlayerAndMoveArgHandler(BoardObject* self, PyObject* args, PyObject* kwds, Tw_Move* move, int* player) +{ + static const char* kwlist[] = + { + "move", + "for_player", + NULL + }; + + MoveObject* moveObj; + *player = Tw_Color_None; + + if (!PyArg_ParseTupleAndKeywords(args, kwds, "O|i", kwlist, &moveObj, player)) + { + *move = (unsigned) Tw_NoMove; + *player = -1; + return false; + } + + if (!PyObject_TypeCheck(moveObj, &MoveType)) + { + PyErr_SetString(PyExc_AttributeError, "Must be a move"); + return false; + } + + *move = moveObj->Move; + + if (*player == Tw_Color_None) + { + *player = self->Board.CurTurn; + } + + if (*player < 0 || *player >= self->Board.NumPlayers) + { + *move = (unsigned) Tw_NoMove; + *player = -1; + PyErr_SetString(PyExc_AttributeError, "for_player must be valid or None"); + return false; + } + + return true; +} + +static PyObject* Board_NumLegalMoves(BoardObject* self, PyObject* args, PyObject* kwds) +{ + int player; + if (!ForPlayerArgHandler(self, args, kwds, &player)) + { + return NULL; + } + + return PyLong_FromLong(Tw_Board_NumMovesForPlayer(&self->Board, (Tw_Color) player)); +} + +static PyObject* Board_PlayerPcs(BoardObject* self, PyObject* args, PyObject* kwds) +{ + int player; + if (!ForPlayerArgHandler(self, args, kwds, &player)) + { + return NULL; + } + + // get a list of the player's pieces + Tw_PcList pcs; + Tw_InitPcList(&pcs); + Tw_Board_PlayerPcs(&self->Board, player, &pcs); + + // build Python list of the pieces + PyObject* list = PyList_New(pcs.Count); + for (int i = 0; i < pcs.Count; i++) { + PyList_SetItem( + list, + i, + PyLong_FromUnsignedLong((unsigned long) pcs.Elements[i]) + ); + } + + return list; +} + +static PyObject* Board_NumPlayerPcs(BoardObject* self, PyObject* args, PyObject* kwds) +{ + int player; + if (!ForPlayerArgHandler(self, args, kwds, &player)) + { + return NULL; + } + + return PyLong_FromLong(Tw_Board_NumPlayerPcs(&self->Board, (Tw_Color) player)); +} + +static PyObject* Board_PlayerCorners(BoardObject* self, PyObject* args, PyObject* kwds) +{ + int player; + if (!ForPlayerArgHandler(self, args, kwds, &player)) + { + return NULL; + } + + // get a list of the player's open corners + Tw_TileList openCorners; + Tw_InitTileList(&openCorners); + Tw_Board_PlayerCorners(&self->Board, player, &openCorners); + + // build Python list of the open corner tiles + PyObject* list = PyList_New(openCorners.Count); + for (int i = 0; i < openCorners.Count; i++) { + PyList_SetItem( + list, + i, + PyLong_FromUnsignedLong((unsigned long) openCorners.Elements[i]) + ); + } + + return list; +} + +static PyObject* Board_PlayerScore(BoardObject* self, PyObject* args, PyObject* kwds) +{ + int player; + if (!ForPlayerArgHandler(self, args, kwds, &player)) + { + return NULL; + } + + return PyLong_FromLong(self->Board.Players[player].Score); +} + +static PyObject* Board_CanPlay(BoardObject* self, PyObject* args, PyObject* kwds) +{ + int player; + if (!ForPlayerArgHandler(self, args, kwds, &player)) + { + return NULL; + } + + return PyBool_FromLong((long) self->Board.Players[player].CanPlay); +} + +static PyObject* Board_NumPlayerCorners(BoardObject* self, PyObject* args, PyObject* kwds) +{ + int player; + if (!ForPlayerArgHandler(self, args, kwds, &player)) + { + return NULL; + } + + return PyLong_FromLong(Tw_Board_NumPlayerCorners(&self->Board, player)); +} + +static PyObject* Board_IsLegal(BoardObject* self, PyObject* args, PyObject* kwds) +{ + int player; + Tw_Move move; + if (!ForPlayerAndMoveArgHandler(self, args, kwds, &move, &player)) + { + return NULL; + } + + return PyBool_FromLong(Tw_Board_IsLegalForPlayer(&self->Board, (Tw_Color) player, move)); +} + +static PyObject* Board_Pop(BoardObject* self, PyObject* Py_UNUSED(ignored)) +{ + Tw_Board_Pop(&self->Board); + Py_RETURN_NONE; +} + +static PyObject* Board_str(BoardObject* self, PyObject* Py_UNUSED(ignored)) +{ + char buf[Tw_BoardStrSize]; + Tw_Board_ToStr(&self->Board, buf); + + return PyUnicode_FromString(buf); +} + +static PyGetSetDef Board_getsets[] = +{ + { "moves", Board_Moves, NULL, "Move history", NULL }, + { "finished", Board_Finished, NULL, "Whether the game is done", NULL }, + { "n_players", Board_NumPlayers, NULL, "Number of players", NULL }, + { "current_player", Board_CurrentPlayer, NULL, "Color of the current player", NULL }, + { "cur_player", Board_CurrentPlayer, NULL, "Color of the current player", NULL }, + { "ply", Board_Ply, NULL, "Current board ply", NULL }, + { "scores", Board_Scores, NULL, "Scores of all players", NULL }, + { "winners", Board_Winners, NULL, "Gets list of player indices who have the highest score", NULL }, + { NULL } +}; + +static PyMethodDef Board_methods[] = +{ + { "generate_legal_moves", Board_GenMoves, METH_VARARGS | METH_KEYWORDS, "Returns a list of legal moves" }, + { "gen_moves", Board_GenMoves, METH_VARARGS | METH_KEYWORDS, "Returns a list of legal moves" }, + { "push", Board_Push, METH_VARARGS | METH_KEYWORDS, "Plays a move" }, + { "pop", Board_Pop, METH_NOARGS, "Undoes a move" }, + { "color_at", Board_ColorAt, METH_VARARGS | METH_KEYWORDS, "Color that claimed the tile" }, + { "n_legal_moves", Board_NumLegalMoves, METH_VARARGS | METH_KEYWORDS, "Gets total number of legal moves for a player" }, + { "n_remaining_pieces", Board_NumPlayerPcs, METH_VARARGS | METH_KEYWORDS, "Gets total number of pieces remaining for a player" }, + { "remaining_pieces", Board_PlayerPcs, METH_VARARGS | METH_KEYWORDS, "Gets a list of pieces remaining for a player" }, + { "n_player_corners", Board_NumPlayerCorners, METH_VARARGS | METH_KEYWORDS, "Gets total number of open corners for a player" }, + { "player_corners", Board_PlayerCorners, METH_VARARGS | METH_KEYWORDS, "Gets a list of the open corners for a player" }, + { "player_score", Board_PlayerScore, METH_VARARGS | METH_KEYWORDS, "Gets the score of a player" }, + { "can_play", Board_CanPlay, METH_VARARGS | METH_KEYWORDS, "Whether a player has remaining moves" }, + { "is_legal", Board_IsLegal, METH_VARARGS | METH_KEYWORDS, "Whether a move is legal for a player" }, + // { "copy", Board_Copy, METH_NOARGS, "Returns a clone of the current board state" }, + { NULL } +}; + +static PyTypeObject BoardType = +{ + .ob_base = PyVarObject_HEAD_INIT(NULL, 0) + .tp_name = "ctilewe.Board", + .tp_doc = PyDoc_STR("Public representation of a game Board which is how players or bots interface with the game."), + .tp_basicsize = sizeof(BoardObject), + .tp_itemsize = 0, + .tp_flags = Py_TPFLAGS_DEFAULT, + .tp_new = PyType_GenericNew, + .tp_init = Board_init, + .tp_str = Board_str, + .tp_getset = Board_getsets, + .tp_methods = Board_methods +}; + +static bool TileArgHandler(PyObject* args, PyObject* kwds, bool checkBounds, Tw_Tile* tile) +{ + static const char* kwlist[] = + { + "tile", + NULL + }; + + *tile = Tw_Tile_None; + + unsigned tileValue; + if (!PyArg_ParseTupleAndKeywords(args, kwds, "I", kwlist, &tileValue)) + { + return false; + } + + *tile = (Tw_Tile) tileValue; + + if (checkBounds && !Tw_Tile_InBounds(*tile)) + { + PyErr_SetString(PyExc_AttributeError, "tile must be in bounds"); + return false; + } + + return true; +} + +static bool CoordsArgHandler(PyObject* args, PyObject* kwds, bool checkBounds, int vals[2]) +{ + static const char* kwlist[] = + { + "coords", + NULL + }; + + vals[0] = vals[1] = 0; + + if (!PyArg_ParseTupleAndKeywords(args, kwds, "(ii)", kwlist, vals, vals + 1)) + { + return false; + } + + if (checkBounds && !Tw_CoordsInBounds(vals[0], vals[1])) + { + PyErr_SetString(PyExc_AttributeError, "coords must be in bounds"); + return false; + } + + return true; +} + +static bool PcArgHandler(PyObject* args, PyObject* kwds, bool checkBounds, Tw_Pc* pc) +{ + static const char* kwlist[] = + { + "piece", + NULL + }; + + *pc = Tw_Pc_None; + + unsigned pcValue; + if (!PyArg_ParseTupleAndKeywords(args, kwds, "I", kwlist, &pcValue)) + { + return false; + } + + *pc = (Tw_Pc) pcValue; + + if (checkBounds && !Tw_Tile_InBounds(*pc)) + { + PyErr_SetString(PyExc_AttributeError, "piece must be valid"); + return false; + } + + return true; +} + +static bool PcRotArgHandler(PyObject* args, PyObject* kwds, bool checkBounds, Tw_Pc* pc, Tw_Rot* rot) +{ + static const char* kwlist[] = + { + "piece", + "rotation", + NULL + }; + + *pc = Tw_Pc_None; + *rot = Tw_Rot_N; + + unsigned pcValue, rotValue; + if (!PyArg_ParseTupleAndKeywords(args, kwds, "II", kwlist, &pcValue, &rotValue)) + { + return false; + } + + *pc = (Tw_Pc) pcValue; + *rot = (Tw_Rot) rotValue; + + if (checkBounds && !Tw_Tile_InBounds(*pc)) + { + PyErr_SetString(PyExc_AttributeError, "piece must be valid"); + return false; + } + + if (checkBounds && (*rot < 0 || *rot >= Tw_NumRots)) + { + PyErr_SetString(PyExc_AttributeError, "rotation must be valid"); + return false; + } + + return true; +} + +static bool MoveArgHandler(PyObject* args, PyObject* kwds, bool checkBounds, Tw_Move* move) +{ + static const char* kwlist[] = + { + "move", + NULL + }; + + *move = Tw_NoMove; + + unsigned moveValue; + if (!PyArg_ParseTupleAndKeywords(args, kwds, "I", kwlist, &moveValue)) + { + return false; + } + + Tw_Move tmp = *move = (Tw_Move) moveValue; + + if (checkBounds && (tmp == Tw_NoMove || tmp != Tw_MakeMove_Safe( + Tw_Move_Pc(tmp), + Tw_Move_Rot(tmp), + Tw_Move_Con(tmp), + Tw_Move_ToTile(tmp) + ))) + { + PyErr_SetString(PyExc_AttributeError, "move must be valid"); + return false; + } + + return true; +} + +static PyObject* Tilewe_TileToCoords(PyObject* self, PyObject* args, PyObject* kwds) +{ + Tw_Tile tile; + if (!TileArgHandler(args, kwds, true, &tile)) + { + return NULL; + } + + int x, y; + Tw_Tile_ToCoords(tile, &x, &y); + + return Py_BuildValue("ii", x, y); +} + +static PyObject* Tilewe_TileInBounds(PyObject* self, PyObject* args, PyObject* kwds) +{ + Tw_Tile tile; + if (!TileArgHandler(args, kwds, false, &tile)) + { + return NULL; + } + + return PyBool_FromLong(Tw_Tile_InBounds(tile)); +} + +static PyObject* Tilewe_CoordsToTile(PyObject* self, PyObject* args, PyObject* kwds) +{ + int vals[2]; + if (!CoordsArgHandler(args, kwds, true, vals)) + { + return NULL; + } + + return Py_BuildValue("i", Tw_MakeTile(vals[0], vals[1])); +} + +static PyObject* Tilewe_CoordsInBounds(PyObject* self, PyObject* args, PyObject* kwds) +{ + int vals[2]; + if (!CoordsArgHandler(args, kwds, false, vals)) + { + return NULL; + } + + return PyBool_FromLong(Tw_CoordsInBounds(vals[0], vals[1])); +} + +static PyObject* Tilewe_NumPcContacts(PyObject* self, PyObject* args, PyObject* kwds) +{ + Tw_Pc pc; + if (!PcArgHandler(args, kwds, true, &pc)) + { + return NULL; + } + + return PyLong_FromLong(Tw_TileSet_Count(&Tw_RotPcInfos[Tw_ToRotPc(pc, Tw_Rot_N)].Contacts)); +} + +static PyObject* Tilewe_NumPcTiles(PyObject* self, PyObject* args, PyObject* kwds) +{ + Tw_Pc pc; + if (!PcArgHandler(args, kwds, true, &pc)) + { + return NULL; + } + + return PyLong_FromLong(Tw_TileSet_Count(&Tw_RotPcInfos[Tw_ToRotPc(pc, Tw_Rot_N)].Tiles)); +} + +static PyObject* Tilewe_NumPcCorners(PyObject* self, PyObject* args, PyObject* kwds) +{ + Tw_Pc pc; + if (!PcArgHandler(args, kwds, true, &pc)) + { + return NULL; + } + + return PyLong_FromLong(Tw_TileSet_Count(&Tw_RotPcInfos[Tw_ToRotPc(pc, Tw_Rot_N)].RelCorners)); +} + +static PyObject* Tilewe_PcTiles(PyObject* self, PyObject* args, PyObject* kwds) +{ + Tw_Pc pc; + Tw_Rot rot; + if (!PcRotArgHandler(args, kwds, true, &pc, &rot)) + { + return NULL; + } + + PyObject* list = PyList_New(0); + + Tw_TileSet_FOR_EACH(Tw_RotPcInfos[Tw_ToRotPc(pc, rot)].Tiles, tile, + { + PyList_Append(list, PyLong_FromLong((long) tile)); + }); + + return list; +} + +static PyObject* Tilewe_PcContacts(PyObject* self, PyObject* args, PyObject* kwds) +{ + Tw_Pc pc; + Tw_Rot rot; + if (!PcRotArgHandler(args, kwds, true, &pc, &rot)) + { + return NULL; + } + + PyObject* list = PyList_New(0); + + Tw_TileSet_FOR_EACH(Tw_RotPcInfos[Tw_ToRotPc(pc, Tw_Rot_N)].Contacts, tile, + { + PyList_Append(list, PyLong_FromLong((long) tile)); + }); + + return list; +} + +static PyObject* Tilewe_MovePc(PyObject* self, PyObject* args, PyObject* kwds) +{ + Tw_Move move; + if (!MoveArgHandler(args, kwds, true, &move)) + { + return NULL; + } + + return PyLong_FromLong(Tw_Move_Pc(move)); +} + +static PyObject* Tilewe_MoveRot(PyObject* self, PyObject* args, PyObject* kwds) +{ + Tw_Move move; + if (!MoveArgHandler(args, kwds, true, &move)) + { + return NULL; + } + + return PyLong_FromLong(Tw_Move_Rot(move)); +} + + +static PyObject* Tilewe_MoveCon(PyObject* self, PyObject* args, PyObject* kwds) +{ + Tw_Move move; + if (!MoveArgHandler(args, kwds, true, &move)) + { + return NULL; + } + + + return PyLong_FromLong(Tw_Move_Con(move)); +} + +static PyObject* Tilewe_MoveTile(PyObject* self, PyObject* args, PyObject* kwds) +{ + Tw_Move move; + if (!MoveArgHandler(args, kwds, true, &move)) + { + return NULL; + } + + return PyLong_FromLong(Tw_Move_ToTile(move)); +} + +static PyObject* Tilewe_CreateMove(PyObject* self, PyObject* args, PyObject* kwds) +{ + static const char* kwlist[] = + { + "piece", + "rotation", + "contact", + "to_tile", + NULL + }; + + unsigned pc, rot, con, tile; + if (!PyArg_ParseTupleAndKeywords(args, kwds, "IIII", kwlist, &pc, &rot, &con, &tile)) + { + // PyErr_SetString(PyExc_AttributeError, "piece must be valid"); + return false; + } + + Tw_Move move = Tw_MakeMove_Safe(pc, rot, con, tile); + + if (move == Tw_NoMove) + { + Py_RETURN_NONE; + } + else + { + PyLong_FromLong(move); + } +} + +static PyObject* Tilewe_PlayRandomGame(PyObject* self, PyObject* args) +{ + Tw_Board board[1]; + Tw_MoveList moves[1]; + Tw_InitBoard(board, 4); + + while (!board->Finished) + { + Tw_InitMoveList(moves); + Tw_Board_GenMoves(board, moves); + Tw_Board_Push(board, moves->Elements[rand() % moves->Count]); + } + + Tw_Board_Print(board); + + Py_RETURN_NONE; +} + +static PyMethodDef TileweMethods[] = +{ + { "play_random_game", Tilewe_PlayRandomGame, METH_NOARGS, "Plays a random game" }, + { "tile_to_coords", Tilewe_TileToCoords, METH_VARARGS | METH_KEYWORDS, "Get x,y coordinates of a tile" }, + { "tile_in_bounds", Tilewe_TileInBounds, METH_VARARGS | METH_KEYWORDS, "Checks if tile is in bounds" }, + { "coords_to_tile", Tilewe_CoordsToTile, METH_VARARGS | METH_KEYWORDS, "Get tile from x,y coordinates" }, + { "coords_in_bounds", Tilewe_CoordsInBounds, METH_VARARGS | METH_KEYWORDS, "Checks if coords are in bounds" }, + { "n_piece_tiles", Tilewe_NumPcTiles, METH_VARARGS | METH_KEYWORDS, "Gets number of tiles in a piece" }, + { "n_piece_contacts", Tilewe_NumPcContacts, METH_VARARGS | METH_KEYWORDS, "Gets number of contacts in a piece" }, + { "n_piece_corners", Tilewe_NumPcCorners, METH_VARARGS | METH_KEYWORDS, "Gets number of corners in a piece" }, + { "piece_tiles", Tilewe_PcTiles, METH_VARARGS | METH_KEYWORDS, "Gets tiles in a rotated piece" }, + { "piece_contacts", Tilewe_PcContacts, METH_VARARGS | METH_KEYWORDS, "Gets contacts in a rotated piece" }, + { NULL, NULL, 0, NULL } +}; + +static PyModuleDef TileweModule = +{ + .m_base = PyModuleDef_HEAD_INIT, + .m_name = "ctilewe", + .m_doc = "Contains Tilewe implementation", + .m_size = -1, + .m_methods = TileweMethods +}; + +PyMODINIT_FUNC PyInit_ctilewe(void) +{ + Tw_Init(); + + PyObject* m; + + if (PyType_Ready(&BoardType) < 0) return NULL; + if (PyType_Ready(&MoveType) < 0) return NULL; + + if (!(m = PyModule_Create(&TileweModule))) return NULL; + + Py_INCREF(&BoardType); + if (PyModule_AddObject(m, "Board", (PyObject*) &BoardType) < 0) + { + Py_DECREF(&BoardType); + Py_DECREF(m); + return NULL; + } + + Py_INCREF(&MoveType); + if (PyModule_AddObject(m, "Move", (PyObject*) &MoveType) < 0) + { + Py_DECREF(&MoveType); + Py_DECREF(m); + return NULL; + } + + return m; +} diff --git a/tilewe/src/tilewe b/tilewe/src/tilewe new file mode 160000 index 0000000..957f097 --- /dev/null +++ b/tilewe/src/tilewe @@ -0,0 +1 @@ +Subproject commit 957f097e6b2eb4443bdf022a12469f9dbd2db4f6 diff --git a/tilewe/tests/test_gameplay.py b/tilewe/tests/test_gameplay.py index 3a87e64..81470bb 100644 --- a/tilewe/tests/test_gameplay.py +++ b/tilewe/tests/test_gameplay.py @@ -30,7 +30,7 @@ def test_no_moves_is_finished(self): # assert that when no more moves can be played, all players have can_play() == False for i in range(board.n_players): - self.assertFalse(board.can_play(player=i)) + self.assertFalse(board.can_play(for_player=i)) def test_finished_game_state(self): random.seed(0) @@ -49,13 +49,10 @@ def test_finished_game_state(self): for i in range(board.n_players): # assert that when the game is marked finished, no moves can be played - self.assertEqual(board.n_legal_moves(unique=True, for_player=i), 0) - self.assertEqual(board.generate_legal_moves(unique=True, for_player=i), []) + self.assertEqual(board.n_legal_moves(for_player=i), 0) + self.assertEqual(board.generate_legal_moves(for_player=i), []) - self.assertEqual(board.n_legal_moves(unique=False, for_player=i), 0) - self.assertEqual(board.generate_legal_moves(unique=False, for_player=i), []) - - self.assertFalse(board.can_play(player=i)) + self.assertFalse(board.can_play(for_player=i)) # assert that when there are no moves, players have 0 open corners self.assertEqual(len(board.player_corners(i)), 0) diff --git a/tilewe/tests/test_moves.py b/tilewe/tests/test_moves.py index b760f77..02d0e34 100644 --- a/tilewe/tests/test_moves.py +++ b/tilewe/tests/test_moves.py @@ -35,44 +35,6 @@ def test_unique_legal_move(self): to_tile=tilewe.T20 ))) - def test_nonunique_legal_move(self): - board = tilewe.Board(4) - - self.assertTrue(board.is_legal(tilewe.Move( - piece=tilewe.Z5, - rotation=tilewe.SOUTH, - contact=tilewe.A03, - to_tile=tilewe.A20 - ))) - - self.assertTrue(board.is_legal(tilewe.Move( - piece=tilewe.Z5, - rotation=tilewe.WEST, - contact=tilewe.A01, - to_tile=tilewe.A01 - ))) - - self.assertTrue(board.is_legal(tilewe.Move( - piece=tilewe.T4, - rotation=tilewe.NORTH_F, - contact=tilewe.A02, - to_tile=tilewe.A20 - ))) - - self.assertTrue(board.is_legal(tilewe.Move( - piece=tilewe.T4, - rotation=tilewe.WEST_F, - contact=tilewe.B03, - to_tile=tilewe.T20 - ))) - - self.assertTrue(board.is_legal(tilewe.Move( - piece=tilewe.O4, - rotation=tilewe.WEST_F, - contact=tilewe.B01, - to_tile=tilewe.T01 - ))) - def test_unique_illegal_move(self): board = tilewe.Board(4) @@ -85,136 +47,87 @@ def test_unique_illegal_move(self): ))) # contact is not valid - self.assertFalse(board.is_legal(tilewe.Move( + self.assertRaises(AttributeError, lambda: tilewe.Move( piece=tilewe.Z5, rotation=tilewe.NORTH, contact=tilewe.A01, to_tile=tilewe.A01 - ))) + )) # contact is invalid, and tiles would be placed off the board - self.assertFalse(board.is_legal(tilewe.Move( + self.assertRaises(AttributeError, lambda: tilewe.Move( piece=tilewe.T4, rotation=tilewe.SOUTH, contact=tilewe.C02, to_tile=tilewe.A20 - ))) + )) - self.assertFalse(board.is_legal(tilewe.Move( + self.assertRaises(AttributeError, lambda: tilewe.Move( piece=None, rotation=tilewe.EAST, contact=tilewe.B03, to_tile=tilewe.T20 - ))) + )) - self.assertFalse(board.is_legal(tilewe.Move( + self.assertRaises(AttributeError, lambda: tilewe.Move( piece=tilewe.T4, rotation=None, contact=tilewe.B03, to_tile=tilewe.T20 - ))) + )) - self.assertFalse(board.is_legal(tilewe.Move( + self.assertRaises(AttributeError, lambda: tilewe.Move( piece=tilewe.T4, rotation=tilewe.EAST, contact=None, to_tile=tilewe.T20 - ))) + )) - self.assertFalse(board.is_legal(tilewe.Move( + self.assertRaises(AttributeError, lambda: tilewe.Move( piece=tilewe.T4, rotation=tilewe.EAST, contact=tilewe.B03, to_tile=None - ))) - - def test_nonunique_illegal_move(self): - board = tilewe.Board(4) - - self.assertFalse(board.is_legal(tilewe.Move( - piece=tilewe.Z5, - rotation=tilewe.SOUTH, - contact=tilewe.A03, - to_tile=tilewe.A01 - ))) - - self.assertFalse(board.is_legal(tilewe.Move( - piece=tilewe.O4, - rotation=tilewe.WEST_F, - contact=tilewe.B01, - to_tile=tilewe.T20 - ))) - - self.assertFalse(board.is_legal(tilewe.Move( - piece=tilewe.T4, - rotation=tilewe.NORTH_F, - contact=tilewe.A02, - to_tile=tilewe.A19 - ))) - - self.assertFalse(board.is_legal(tilewe.Move( - piece=tilewe.T4, - rotation=tilewe.WEST_F, - contact=tilewe.B02, - to_tile=tilewe.T20 - ))) - - def test_nonunique_move_gen_legal(self): - board = tilewe.Board(4) - - moves = board.generate_legal_moves(unique=False) - - for move in moves: - self.assertTrue(board.is_legal(move)) - - def test_nonunique_move_gen_has_unique_move(self): - board = tilewe.Board(4) - - unique = board.generate_legal_moves(unique=True) - moves = board.generate_legal_moves(unique=False) - - for move in moves: - self.assertTrue(move.to_unique() in unique) + )) def test_n_legal_moves(self): board = tilewe.Board(4) - self.assertTrue(board.n_legal_moves(unique=True) == len(board.generate_legal_moves(unique=True))) - self.assertTrue(board.n_legal_moves(unique=False) == len(board.generate_legal_moves(unique=False))) - - def test_null_move_1_player(self): - board = tilewe.Board(1) - - self.assertEqual(board.current_player, tilewe.BLUE) - board.push_null() - self.assertEqual(board.current_player, tilewe.BLUE) - board.pop_null() - self.assertEqual(board.current_player, tilewe.BLUE) - - def test_null_move_2_player(self): - board = tilewe.Board(2) - - self.assertEqual(board.current_player, tilewe.BLUE) - board.push_null() - self.assertEqual(board.current_player, tilewe.YELLOW) - board.pop_null() - self.assertEqual(board.current_player, tilewe.BLUE) - - def test_null_move_3_player(self): - board = tilewe.Board(3) - - self.assertEqual(board.current_player, tilewe.BLUE) - board.push_null() - self.assertEqual(board.current_player, tilewe.YELLOW) - board.push_null() - self.assertEqual(board.current_player, tilewe.RED) - board.push_null() - self.assertEqual(board.current_player, tilewe.BLUE) - board.pop_null() - board.pop_null() - self.assertEqual(board.current_player, tilewe.YELLOW) - board.pop_null() - self.assertEqual(board.current_player, tilewe.BLUE) + self.assertTrue(board.n_legal_moves() == len(board.generate_legal_moves())) + + # def test_null_move_1_player(self): + # board = tilewe.Board(1) + + # self.assertEqual(board.current_player, tilewe.BLUE) + # board.push_null() + # self.assertEqual(board.current_player, tilewe.BLUE) + # board.pop_null() + # self.assertEqual(board.current_player, tilewe.BLUE) + + # def test_null_move_2_player(self): + # board = tilewe.Board(2) + + # self.assertEqual(board.current_player, tilewe.BLUE) + # board.push_null() + # self.assertEqual(board.current_player, tilewe.YELLOW) + # board.pop_null() + # self.assertEqual(board.current_player, tilewe.BLUE) + + # def test_null_move_3_player(self): + # board = tilewe.Board(3) + + # self.assertEqual(board.current_player, tilewe.BLUE) + # board.push_null() + # self.assertEqual(board.current_player, tilewe.YELLOW) + # board.push_null() + # self.assertEqual(board.current_player, tilewe.RED) + # board.push_null() + # self.assertEqual(board.current_player, tilewe.BLUE) + # board.pop_null() + # board.pop_null() + # self.assertEqual(board.current_player, tilewe.YELLOW) + # board.pop_null() + # self.assertEqual(board.current_player, tilewe.BLUE) def test_legal_move_gen_full_game(self): board = tilewe.Board(4) diff --git a/tilewe/tournament.py b/tilewe/tournament.py index 68a0a76..6cce4f9 100644 --- a/tilewe/tournament.py +++ b/tilewe/tournament.py @@ -9,7 +9,7 @@ import tilewe from tilewe.engine import Engine -from tilewe.elo import compute_elo_adjustment_n +from tilewe.elo import compute_elo_adjustment_n, compute_elo_error_margin, compute_estimated_elo @dataclass class MatchData: @@ -119,13 +119,35 @@ def avg_scores(self) -> list[float]: def elo_delta(self) -> list[float]: return [self.elo_end[i] - self.elo_start[i] for i in range(self.total_engines)] + @property + def elo_error_margin(self) -> list[float]: + # with default 95% confidence and C=400 + return [ + compute_elo_error_margin( + self.win_counts[i], + self.draw_counts[i], + self.lose_counts[i] + ) for i in range(self.total_engines) + ] + @property def average_match_duration(self) -> float: return self.total_time / max(1, self.total_games) def get_matches_by_engine(self, engine: int) -> list[MatchData]: + # filter the matches for those involving this engine return [x for x in self.match_data if engine in x.engines] - + + def get_elo_error_margin(self, engine: int, confidence: float=0.95, C: int=400) -> float: + # supports custom confidence and C values + return compute_elo_error_margin( + self.win_counts[engine], + self.draw_counts[engine], + self.lose_counts[engine], + max(0.001, min(0.999, confidence)), + max(1, C) + ) + def get_engine_rankings_display(self, sort_by: str = 'elo_end', sort_dir: str = 'desc') -> str: # verify the given sort property exists if not hasattr(self, sort_by): @@ -147,10 +169,13 @@ def get_engine_rankings_display(self, sort_by: str = 'elo_end', sort_dir: str = len_names = max(5, min(24, max([len(x) for x in self.engine_names]) + 1)) len_score = max(6, max([math.floor(math.log10(max(1, self.total_scores[i])) + 1) for i in range(N)]) + 1) len_games = max(7, max([math.floor(math.log10(max(1, self.game_counts[i])) + 1) for i in range(N)]) + 1) + len_elo = max(4, max([math.floor(math.log10(max(1, abs( + self.elo_end[i] if math.isfinite(self.elo_end[i]) else 0 + ))) + 1) for i in range(N)]) + 1) out = f"Ranking by {sort_by} {sort_dir}:\n" - out += f"{'Rank':4} {'Name':{len_names}} {'Elo':>5} {'Score':>{len_score}} {'Avg Score':>10} {'Games':>{len_games}} " - out += f"{'Wins':>{len_games}} {'Draws':>{len_games}} {'Losses':>{len_games}} {'Win %':>7}\n" + out += f"{'Rank':4} {'Name':{len_names}} {'Elo':^{len_elo + 9}} {'Score':>{len_score}} {'Avg Score':>10} " + out += f"{'Games':>{len_games}} {'Wins':>{len_games}} {'Draws':>{len_games}} {'Losses':>{len_games}} {'Win Rate':>9}\n" dir = -1 if sort_dir == 'desc' else 1 @@ -158,11 +183,13 @@ def get_engine_rankings_display(self, sort_by: str = 'elo_end', sort_dir: str = name = self.engine_names[engine] draws, wins, games = self.draw_counts[engine], self.win_counts[engine], self.game_counts[engine] losses, score, elo = games - wins - draws, self.total_scores[engine], self.elo_end[engine] + elo_margin = compute_elo_error_margin(wins, draws, losses) - win_rate = f"{(wins / games * 100):>6.2f}%" if games > 0 else f"{'-':>7}" + win_rate = f"{(wins / games * 100):>8.2f}%" if games > 0 else f"{'-':>9}" avg_score = f"{(score / games):>10.2f}" if games > 0 else f"{'-':>10}" + elo_range = f"{elo:>{len_elo}.0f} +/- {elo_margin:<4.0f}" - out += f"{rank:>4d} {name:{len_names}.{len_names}} {elo:>5.0f} {score:>{len_score}d} {avg_score} " + out += f"{rank:>4d} {name:{len_names}.{len_names}} {elo_range} {score:>{len_score}d} {avg_score} " out += f"{games:>{len_games}d} {wins:>{len_games}d} {draws:>{len_games}d} {losses:>{len_games}d} {win_rate}\n" return out @@ -207,15 +234,16 @@ def __init__(self, engines: list[Engine], move_seconds: int=60): self._seconds = move_seconds self.move_seconds = self._seconds - def play( + def play( # noqa: C901 self, n_games: int, n_threads: int=1, players_per_game: int=4, move_seconds: int=None, verbose_board: bool=False, - verbose_rankings: bool=True - ): + verbose_rankings: bool=True, + elo_mode: str="estimated", + ): """ Used to launch a series of games in an initialized Tournament. @@ -233,6 +261,8 @@ def play( Whether or not to print the final board state of each match verbose_rankings : bool=True Whether or not to print periodic ranking updates and the final rankings at the end + use_starting_elos : bool=False + Whether or not to initialize engines with their self proposed estimated Elo, otherwise 0 """ if n_games <= 0: @@ -241,6 +271,8 @@ def play( raise Exception("Must use at least one thread") if players_per_game < 1 or players_per_game > 4: raise Exception("Must have 1 to 4 players per game") + if elo_mode not in ["estimated", "live", "live_initial"]: + raise Exception("Elo mode must be 'estimated', 'live', or 'live_initial'") self.move_seconds = move_seconds if move_seconds is not None else self._seconds if self.move_seconds <= 0: @@ -249,34 +281,46 @@ def play( # initialize trackers and game controls N = len(self.engines) total_games = 0 - draws = [0 for _ in range(N)] - wins = [0 for _ in range(N)] - games = [0 for _ in range(N)] - elos = [0 for _ in range(N)] - totals = [0 for _ in range(N)] + draws = [0] * N + wins = [0] * N + games = [0] * N + totals = [0] * N + + if elo_mode == "live_initial": + elos = [0 if self.engines[i].estimated_elo is None else self.engines[i].estimated_elo for i in range(N)] + else: + elos = [0] * N initial_elos = [i for i in elos] match_results: list[MatchData] = [] + player_inds_per_game: list[list[int]] = [] + player_results_per_game: list[list[float]] = [] + # helper for getting engine rank summaries def get_engine_rankings() -> str: len_name = max(5, min(24, max([len(x.name) for x in self.engines]) + 1)) len_score = max(6, max([math.floor(math.log10(max(1, totals[i])) + 1) for i in range(N)]) + 1) len_games = max(7, max([math.floor(math.log10(max(1, games[i])) + 1) for i in range(N)]) + 1) + len_elo = max(4, max([math.floor( + math.log10(max(1, abs(elos[i] if math.isfinite(elos[i]) else 0))) + 1 + ) for i in range(N)]) + 1) - out = f"\n{'Rank':4} {'Name':{len_name}} {'Elo':>5} {'Score':>{len_score}} {'Avg Score':>10} " + out = f"\n{'Rank':4} {'Name':{len_name}} {'Elo':^{len_elo + 9}} {'Score':>{len_score}} {'Avg Score':>10} " out += f"{'Games':>{len_games}} {'Wins':>{len_games}} {'Draws':>{len_games}} " - out += f"{'Losses':>{len_games}} {'Win %':>7}\n" + out += f"{'Losses':>{len_games}} {'Win Rate':>9}\n" for rank, engine in enumerate(sorted(range(N), key=lambda x: -elos[x])): name = self.engines[engine].name draw_count, win_count, game_count = draws[engine], wins[engine], games[engine] loss_count, score, elo = game_count - win_count - draw_count, totals[engine], elos[engine] + elo_margin = compute_elo_error_margin(win_count, draw_count, loss_count) - win_rate = f"{(win_count / game_count * 100):>6.2f}%" if game_count > 0 else f"{'-':>7}" + win_rate = f"{(win_count / game_count * 100):>8.2f}%" if game_count > 0 else f"{'-':>9}" avg_score = f"{(score / game_count):>10.2f}" if game_count > 0 else f"{'-':>10}" + elo_range = f"{elo:>{len_elo}.0f} +/- {elo_margin:<4.0f}" - out += f"{rank:>4d} {name:{len_name}.{len_name}} {elo:>5.0f} {score:>{len_score}d} " + out += f"{rank:>4d} {name:{len_name}.{len_name}} {elo_range} {score:>{len_score}d} " out += f"{avg_score} {game_count:>{len_games}d} {win_count:>{len_games}d} " out += f"{draw_count:>{len_games}d} {loss_count:>{len_games}d} {win_rate}\n" @@ -300,9 +344,18 @@ def get_engine_rankings() -> str: with multiprocessing.Pool(n_threads, initializer=init_func, initargs=init_args) as pool: try: - for winners, scores, board, player_to_engine, time_sec in pool.imap_unordered(self._play_game, args): + for winners, scores, moves, player_to_engine, time_sec in pool.imap_unordered(self._play_game, args): + + # re-build the board state from the moves + board = tilewe.Board(len(player_to_engine)) + for move in moves: + board.push(move) + # at least one player always wins, otherwise the game crashed if len(winners) > 0: + player_inds_per_game.append(player_to_engine) + results = [0] * len(player_to_engine) + # track games played total_games += 1 for p in player_to_engine: @@ -311,9 +364,13 @@ def get_engine_rankings() -> str: # track wins and draws if len(winners) == 1: wins[winners[0]] += 1 + results[player_to_engine.index(winners[0])] = 1 else: for p in winners: draws[p] += 1 + results[player_to_engine.index(p)] = 0.5 + + player_results_per_game.append(results) # track scores and time for p, s in enumerate(scores): @@ -325,14 +382,21 @@ def get_engine_rankings() -> str: player_names = [self.engines[i].name for i in game_players] player_scores = [scores[i] for i in game_players] winner_names = [self.engines[i].name for i in winners] - - # if there are enough players, compute elo changes + + player_elos = [elos[i] for i in game_players] if board.n_players > 1: - player_elos = [elos[i] for i in game_players] - delta_elos = compute_elo_adjustment_n(player_elos, player_scores, K=8) - for index, player in enumerate(game_players): - elos[player] += delta_elos[index] - new_elos = [elos[i] for i in game_players] + # if there are enough players, compute elo changes + if elo_mode == "estimated": + est_elos = compute_estimated_elo(N, player_inds_per_game, player_results_per_game) + delta_elos = [est_elos[i] - elos[i] for i in game_players] + elos = est_elos + else: + delta_elos = compute_elo_adjustment_n(player_elos, player_scores, K=8) + for index, player in enumerate(game_players): + elos[player] += delta_elos[index] + else: + delta_elos = [0 for _ in game_players] + new_elos = [elos[i] for i in game_players] # save match data match_data = MatchData( @@ -412,8 +476,15 @@ def _play_game(self, player_to_engine: list[int]) -> tuple[list[int], list[int], try: engine_to_player = { value: key for key, value in enumerate(player_to_engine) } while not board.finished: - engine = self.engines[player_to_engine[board.current_player]] - move = engine.search(board.copy_current_state(), self.move_seconds) + + # ghetto board copy to avoid exposing the real board to the engine + board_copy = tilewe.Board(n_players=len(player_to_engine)) + for move in board.moves: + board_copy.push(move) + engine = self.engines[player_to_engine[board_copy.current_player]] + + move = engine.search(board_copy, self.move_seconds) + # move = engine.search(board.copy_current_state(), self.move_seconds) # TODO test legality board.push(move) end_time = time.time() @@ -422,7 +493,7 @@ def _play_game(self, player_to_engine: list[int]) -> tuple[list[int], list[int], winners = [ player_to_engine[x] for x in board.winners ] scores = [ board.scores[engine_to_player[i]] if i in engine_to_player else 0 for i in range(len(self.engines)) ] - return winners, scores, board, player_to_engine, end_time - start_time + return winners, scores, board.moves, player_to_engine, end_time - start_time except BaseException: traceback.print_exc()