3
\$\begingroup\$

This is an updated version of my Tic Tac Toe game with AI players, it is related to GUI Tic-Tac-Toe game with six AI players - part 1: the UI. But that question is more than two weeks old and no one has posted an answer. I have spent well over two months on this project and I don't know if I did it right, I just patched everything together and fixed every bug I found.

I have posted part 2: GUI Tic Tac Toe with unbeatable AI - styling

The code works, but I am not sure about the code quality, I don't know if my code is Pythonic and if my code is inefficient, so I really want an review. Because the linked question didn't get answers, I spent the last few days writing new AI for the game, I reused most of the code but I got rid of the GUI customization to cut down code size, but still the code is more than 65536 characters long. So I will post two questions.

This question will cover the GUI and game logic.

enter image description here

Let me explain the GUI, on the left there are 9 squares arranged in a 3x3 square, that is the game board and displays the state of the game. If it is interactive mouse hovering over an unoccupied cell will highlight the cell, showing that you can make the corresponding move. If you click the cell, your move will be registered and be reflected by the cell, and your AI opponent will immediately make its move if the game isn't over, and the game state will be judged to determine if there is a winner, and the winning line will be highlighted.

enter image description here

On the right there are two tall boxes, each corresponds to a player. The top label indicate which player the box corresponds to, the second label from top indicate the current player at that turn order (player 1 moves first, and players move alternatively), and below that the scores for the current player.

Below the scores is the combobox for selecting players for the positions. Available selections are "Human", "Novice AI", "Adept AI", "Adept AI+", "Master AI" and "Master AI+". The choices made determine who will play in the next game in the corresponding position, the changes take effect after the current game is over if any player has moved, or immediately if the game isn't started. No player can occupy two positions, if you select the same names for player 1 and player 2, the other player will be automatically switched so that players are different. For example, if player 1 is "Human" and you changed player 2 to "Human", then player 1 will be automatically switched to a random AI player.

If either position is set to "Human", the game board will become interactive after a running game between AIs is manually stopped if there is such a game. And then you will play against your chosen opponent in the selected position, and your opponent will automatically make its move if you choose to be "Player 2". A new game will automatically start when the current game is over and board is reset, if you choose to be "Player 2", and your opponent will always automatically make a move whenever you make a move and the game isn't over.

The game state is judged automatically whenever a player moves, and winning line will be highlighted before the board resets if there is a winner. If you choose to play the game by selecting "Human" in either combobox, you can choose to enable popup messages by checking the checkbox located at the lower left corner. Then popup windows will be shown when the game is over, and the board won't reset until the popup window is closed.

The windows are:

enter image description hereenter image description hereenter image description here

If either active player is "Human", the game board will be interactive and the popup checkbox will be enabled and the button located at the lower right corner will be disabled. If neither active player is set to "Human", in other words both are set to an AI player, the board will become non-interactive, cells can't be highlighted or respond to your clicks when in this mode, popup checkbox will be disabled and no popup windows can be shown.

And the "Run" button will be enabled, after you click it, the selected AI players will automatically play against each other, and the "Run" button's text will be replaced by "Stop". The AI players automatically make moves every 125 milliseconds, game after game will be animated non-stop so that you can spectate, and the game will only stop when the stop button is clicked and the last active game is over.

The next two comboboxes below the player selection box are for the customization of the pieces that reflect the corresponding player on the board. Customization is tied to either "Player 1" or "Player 2". The first is for customizing the shape, there are 24 shapes available, the second for blend mode, and 27 blend modes are available, blend mode affects the appearance of the shape, and changes take effect immediately and the board is updated in real time, and the change is also reflected by the big square button below.

The textbox below is for setting the shape's general color, you can choose any color from all possible 16777216 RGB colors, you can only enter 6 hexadecimal digits, invalid key presses won't cause the textbox to change text, change is applied when the textbox's text is valid and you press enter or the textbox loses focus, and when the text is invalid and the textbox loses focus, the last valid text is restored.

Clicking the big square button below allows you to change color via a dedicated window, change is applied when you press OK.

enter image description here

The game remembers the player's scores and last active players and their customization, and will restore to the last settings when the game is reopened.

The long bar below the boxes is for showing general stats of the game, the first for count of the game so far and increments after every game, the second for showing the turn count and increments every time a player moves and resets after game over, the third indicates which player can move, the last indicate winner of the last game when game over.

Lastly, the AIs are implemented using reinforcement learning, moves that lead to a win or tie are more likely to be repeated. Novice AI moves randomly but will block your win and fill the gap when it can win. Adept AI uses calculated weights based on principles of reinforcement learning, and Master AI is pretrained using the calculated weights against another AI model which also uses the weights. It has played 1048576 games against a learning opponent in both position, and as a result, Master AI can never be beaten whether it plays first or second.

That is right, Master AI is unbeatable if you play against it, the best you can do is to get a tie. Adept AI+ is also unbeatable, but only when it plays first, the + part indicates that the AI will only choose moves that have the most weight, games between Master AI and Master AI+ always end in a tie, and so are games between Adept AI+ and either when Adept AI+ moves first.


Code

main2.py

import ctypes
import random
import sys
from advanced2 import *
from basics2 import *
from gamecontrol2 import STATSPATH
from PyQt6.QtGui import QCloseEvent
from PyQt6.QtTest import QTest
from PyQt6.QtWidgets import QApplication, QMainWindow
from shared2 import *
from theme2 import *


class Window(QMainWindow):
    def __init__(self) -> None:
        super().__init__()
        GLOBALS["Window"] = self
        self.initialized = False
        self.setFixedSize(622, 438)
        self.init_GUI()
        self.setup_widgets()
        self.add_widgets()
        self.setup_connections()
        self.setStyleSheet(STYLE)
        self.show()
        self.initialized = True
        GLOBALS["Game"].auto_start()
        if GLOBALS["Game"].match:
            self.popup_box.setDisabled(False)

    def init_GUI(self) -> None:
        self.setWindowFlags(
            Qt.WindowType.FramelessWindowHint
            | Qt.WindowType.MSWindowsFixedSizeDialogHint
        )
        frame = self.frameGeometry()
        center = self.screen().availableGeometry().center()
        frame.moveCenter(center)
        self.move(frame.topLeft())
        self.setWindowIcon(GLOBALS["ICON"])
        self.centralwidget = QWidget(self)
        self.setCentralWidget(self.centralwidget)
        self.setWindowTitle("Tic Tac Toe")

    def setup_widgets(self) -> None:
        self.vbox = make_vbox(self.centralwidget, 0)
        self.vbox.addWidget(TitleBar("Tic Tac Toe", exitgame))
        self.hbox = make_hbox()
        GLOBALS["POPUP"] = self.popup_box = CheckBox("Popup messages")
        self.run = GLOBALS["rungamebutton"] = Button("Run")
        self.board = Board()
        self.player1 = Player("Player 1", "P1")
        self.player2 = Player("Player 2", "P2")
        self.statsbar = StatsBar()
        self.underbar = QHBoxLayout()

    def add_widgets(self) -> None:
        self.hbox.addWidget(self.board)
        self.hbox.addWidget(self.player1)
        self.hbox.addWidget(self.player2)
        self.vbox.addLayout(self.hbox)
        self.vbox.addWidget(self.statsbar)
        self.underbar.addWidget(self.popup_box)
        self.underbar.addStretch()
        self.underbar.addWidget(self.run)
        self.vbox.addLayout(self.underbar)

    def setup_connections(self) -> None:
        self.popup_box.clicked.connect(self.set_popup)
        self.run.clicked.connect(self.toggle)
        self.underbar.addWidget(self.run)

    def check_pairing(self) -> None:
        if (
            self.initialized
            and self.player1.item["Player"] == self.player2.item["Player"]
        ):
            if self.player1.changed:
                self.ensure_opponent(self.player1, self.player2)
            else:
                self.ensure_opponent(self.player2, self.player1)

    def ensure_opponent(self, new: Player, old: Player) -> None:
        new.changed = False
        box = old.player_choice_box
        box.currentTextChanged.disconnect(old.update_player)
        choice = random.choice(PLAYERS["opponents"][new.item["Player"]])
        old.player_choice_box.setCurrentText(choice)
        old.item["Player"] = choice
        old.set_player_info()
        box.currentTextChanged.connect(old.update_player)

    def toggle(self) -> None:
        self.run.setText(next(SWITCH))
        GLOBALS["rungame"] ^= 1
        if GLOBALS["rungame"]:
            GLOBALS["Game"].start()

    def set_popup(self) -> None:
        GLOBALS["popup"] = self.popup_box.isChecked()

    def closeEvent(self, e: QCloseEvent) -> None:
        exitgame()


def exitgame() -> None:
    GLOBALS["rungame"] = False
    STATSPATH.write_text(json.dumps(GLOBALS["Game"].stats, indent=4))
    PLAYER_SETTINGS_PATH.write_text(json.dumps(PLAYER_SETTINGS, indent=4))
    QTest.qWait(125)
    for window in QApplication.topLevelWidgets():
        window.close()


if __name__ == "__main__":
    app = QApplication([])
    app.setStyle("Fusion")
    ctypes.windll.shell32.SetCurrentProcessExplicitAppUserModelID("Tic Tac Toe")
    GLOBALS["ICON"] = QIcon(f"{FOLDER}/Icons/logo.png")
    GLOBALS["Logo"] = QPixmap(QImage(f"{FOLDER}/Icons/logo.png")).scaled(
        32, 32, Qt.AspectRatioMode.KeepAspectRatio
    )
    GLOBALS["Close"] = QIcon(f"{FOLDER}/Icons/close.png")
    GLOBALS["Minimize"] = QIcon(f"{FOLDER}/Icons/minimize.png")
    GLOBALS["win_emoji"] = QPixmap(QImage(f"{FOLDER}/Icons/win.png"))
    GLOBALS["loss_emoji"] = QPixmap(QImage(f"{FOLDER}/Icons/loss.png"))
    GLOBALS["tie_emoji"] = QPixmap(QImage(f"{FOLDER}/Icons/tie.png"))
    window = Window()
    sys.exit(app.exec())

basics2.py

from PyQt6.QtCore import Qt
from PyQt6.QtGui import (
    QFocusEvent,
    QFont,
    QFontMetrics,
    QMouseEvent,
    QShowEvent,
)
from PyQt6.QtWidgets import (
    QAbstractButton,
    QCheckBox,
    QColorDialog,
    QComboBox,
    QGridLayout,
    QGroupBox,
    QHBoxLayout,
    QLabel,
    QLineEdit,
    QPushButton,
    QVBoxLayout,
    QWidget,
)
from typing import Callable, Iterable
from shared2 import *
from theme2 import STYLE

ALIGNMENT = Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignTop


class Font(QFont):
    def __init__(self, size: int = 10) -> None:
        super().__init__()
        self.setFamily("Times New Roman")
        self.setStyleHint(QFont.StyleHint.Times)
        self.setStyleStrategy(QFont.StyleStrategy.PreferAntialias)
        self.setPointSize(size)
        self.setBold(True)
        self.setHintingPreference(QFont.HintingPreference.PreferFullHinting)


FONT = Font()
FONT_RULER = QFontMetrics(FONT)


def make_hbox(parent: QWidget = None, margin: int = 3) -> QHBoxLayout:
    return make_box(QHBoxLayout, parent, margin)


def make_vbox(parent: QWidget = None, margin: int = 3) -> QVBoxLayout:
    return make_box(QVBoxLayout, parent, margin)


class CustomLabel(QLabel):
    def __init__(self, text: str, name: str, width: int, height: int) -> None:
        super().__init__()
        self.setFont(FONT)
        self.setText(text)
        self.setFixedSize(width, height)
        self.setObjectName(name)


class CenterLabel(CustomLabel):
    def __init__(self, text: str, name: str, width: int, height: int) -> None:
        super().__init__(text, name, width, height)
        self.setAlignment(Qt.AlignmentFlag.AlignCenter)


class LongScore(CenterLabel):
    def __init__(self, text: str, name: str) -> None:
        super().__init__(text, name, 42, 16)


class CheckBox(QCheckBox):
    def __init__(self, text: str) -> None:
        super().__init__()
        self.setFont(FONT)
        self.setText(text)


class Box(QGroupBox):
    def __init__(self) -> None:
        super().__init__()
        self.setAlignment(ALIGNMENT)
        self.setContentsMargins(0, 0, 0, 0)


class Button(QPushButton):
    def __init__(self, text: str) -> None:
        super().__init__()
        self.setFont(FONT)
        self.setFixedSize(72, 20)
        self.setText(text)


class ComboBox(QComboBox):
    def __init__(self, texts: Iterable[str]) -> None:
        super().__init__()
        self.setFont(FONT)
        self.addItems(texts)
        self.setContentsMargins(3, 3, 3, 3)
        self.setFixedWidth(100)


def make_box(
    box_type: type[QHBoxLayout] | type[QVBoxLayout] | type[QGridLayout],
    parent: QWidget,
    margin: int,
) -> QHBoxLayout | QVBoxLayout | QGridLayout:
    box = box_type(parent) if parent else box_type()
    box.setAlignment(ALIGNMENT)
    box.setContentsMargins(*[margin] * 4)
    return box


class Label(QLabel):
    def __init__(self, text: str) -> None:
        super().__init__()
        self.setFont(FONT)
        self.set_text(text)

    def autoResize(self) -> None:
        self.Height = FONT_RULER.size(0, self.text()).height()
        self.Width = FONT_RULER.size(0, self.text()).width()
        self.setFixedSize(self.Width + 3, self.Height + 3)

    def set_text(self, text: str) -> None:
        self.setText(text)
        self.autoResize()


class ColorEdit(QLineEdit):
    def __init__(self, text: str) -> None:
        super().__init__()
        self.setFixedSize(72, 20)
        self.setFont(FONT)
        self.setInputMask("\#HHHHHH")
        self.setText(text)
        self.color = text

    def focusOutEvent(self, e: QFocusEvent) -> None:
        super().focusOutEvent(e)
        if len(self.text()) != 7:
            self.setText(self.color)
        else:
            self.returnPressed.emit()


class ColorPicker(QWidget):
    instances = []

    def __init__(self) -> None:
        super().__init__()
        self.init_window()
        self.add_dialog()
        self.set_style()
        self.dialog.accepted.connect(self.close)
        self.dialog.rejected.connect(self.close)
        ColorPicker.instances.append(self)

    def init_window(self) -> None:
        self.setWindowIcon(GLOBALS["ICON"])
        self.setWindowTitle("Color Picker")
        self.setWindowFlags(
            Qt.WindowType.FramelessWindowHint
            | Qt.WindowType.MSWindowsFixedSizeDialogHint
        )
        self.vbox = make_vbox(self, 0)
        self.vbox.addWidget(TitleBar("Color Picker", self.close, False))
        self.setFixedSize(518, 436)
        self.frame = self.frameGeometry()
        center = self.screen().availableGeometry().center()
        self.frame.moveCenter(center)
        self.setObjectName("Picker")

    def add_dialog(self) -> None:
        self.dialog = QColorDialog()
        self.dialog.setWindowFlag(Qt.WindowType.FramelessWindowHint)
        grid = self.dialog.findChild(QGridLayout)
        for i in range(grid.count()):
            grid.itemAt(i).widget().setFont(FONT)

        for e in self.dialog.findChildren(QAbstractButton):
            e.setFont(FONT)
            e.setMinimumWidth(72)
            e.setFixedHeight(20)

        for child in self.dialog.children():
            if isinstance(child, QWidget):
                child.setFont(FONT)

        self.vbox.addWidget(self.dialog)

    def showEvent(self, e: QShowEvent) -> None:
        self.move(self.frame.topLeft())
        self.dialog.show()
        e.accept()

    def set_style(self) -> None:
        self.setStyleSheet(STYLE)


class SquareButton(QPushButton):
    def __init__(self, icon: str) -> None:
        super().__init__()
        self.setIcon(GLOBALS[icon])
        self.setFixedSize(32, 32)


class TitleBar(QGroupBox):
    def __init__(self, name: str, exitfunc: Callable, minimize: bool = True) -> None:
        super().__init__()
        self.hbox = make_hbox(self, 0)
        self.name = name
        self.exitfunc = exitfunc
        self.start = None
        self.setObjectName("Title")
        self.add_icon()
        self.add_title()
        self.add_buttons(minimize)
        self.setFixedHeight(32)

    def add_icon(self) -> None:
        self.icon = QLabel()
        self.icon.setPixmap(GLOBALS["Logo"])
        self.icon.setFixedSize(32, 32)
        self.hbox.addWidget(self.icon)
        self.hbox.addStretch()

    def add_title(self) -> None:
        self.label = Label(self.name)
        self.hbox.addWidget(self.label)
        self.hbox.addStretch()

    def add_buttons(self, minimize: bool = True) -> None:
        if minimize:
            self.minimize_button = SquareButton("Minimize")
            self.minimize_button.clicked.connect(self.minimize)
            self.hbox.addWidget(self.minimize_button)

        self.close_button = SquareButton("Close")
        self.close_button.clicked.connect(self.exitfunc)
        self.hbox.addWidget(self.close_button)

    def mousePressEvent(self, e: QMouseEvent) -> None:
        if e.button() == Qt.MouseButton.LeftButton:
            self.start = e.position()

    def mouseMoveEvent(self, e: QMouseEvent) -> None:
        if self.start is not None:
            self.window().move((e.globalPosition() - self.start).toPoint())

    def mouseReleaseEvent(self, e: QMouseEvent) -> None:
        self.start = None

    def minimize(self) -> None:
        self.window().showMinimized()


class MessageBox(QWidget):
    instances = []

    def __init__(self, title: str, text: str, icon: str, option: str) -> None:
        super().__init__()
        MessageBox.instances.append(self)
        self.setObjectName("Window")
        self.title = title
        self.init_window()
        self.setFixedSize(200, 136)
        self.frame = self.frameGeometry()
        center = self.screen().availableGeometry().center()
        self.frame.moveCenter(center)
        self.add_widgets(text, icon, option)
        self.set_style()

    def init_window(self) -> None:
        self.setWindowTitle(self.title)
        self.setWindowIcon(GLOBALS["ICON"])
        self.setWindowFlags(
            Qt.WindowType.FramelessWindowHint
            | Qt.WindowType.MSWindowsFixedSizeDialogHint
        )
        self.vbox = make_vbox(self, 0)
        self.vbox.addWidget(TitleBar(self.title, self.close, False))
        self.hbox = QHBoxLayout()
        self.vbox.addLayout(self.hbox)
        self.hbox.addStretch()
        self.buttonbar = QHBoxLayout()
        self.buttonbar.addStretch()
        self.vbox.addLayout(self.buttonbar)

    def add_widgets(self, text: str, icon: str, option: str) -> None:
        self.icon = CenterLabel("", "", 72, 72)
        self.icon.setPixmap(GLOBALS[icon])
        self.hbox.addWidget(self.icon)
        self.label = Label(text)
        self.label.setObjectName(self.title)
        self.hbox.addWidget(self.label)
        self.hbox.addStretch()
        self.ok = Button(option)
        self.ok.clicked.connect(self.close)
        self.buttonbar.addWidget(self.ok)

    def set_style(self) -> None:
        self.setStyleSheet(STYLE)

    def showEvent(self, e: QShowEvent) -> None:
        self.move(self.frame.topLeft())
        e.accept()

advanced2.py

import json
from basics2 import *
from gamecontrol2 import Game
from logic2 import *
from PyQt6.QtCore import QSize, Qt, pyqtSignal
from PyQt6.QtGui import QColor
from PyQt6.QtWidgets import QAbstractItemView, QTableWidget
from shared2 import *
from theme2 import *


CELLSTYLES = {
    "P1": """QLabel#P1 {
        background: qlineargradient(x1: 0, y1: 0, x2: 0, y2: 0.75, x3: 0, y3: 1, stop: 0 #6345bf, stop: 0.75 #855cff, stop: 1 #6345bf);
        border: 3px inset #4000ff;
        border-radius: 6px;
    }""",
    "P2": """QLabel#P2 {
        background: qlineargradient(x1: 0, y1: 0, x2: 0, y2: 0.75, x3: 0, y3: 1, stop: 0 #a36629, stop: 0.75 #ff9f40, stop: 1 #a36629);
        border: 3px inset #ff8000;
        border-radius: 6px;
    }""",
    "P1Win": """QLabel#P1Win {
        background: qlineargradient(x1: 0, y1: 0, x2: 0, y2: 0.75, x3: 0, y3: 1, stop: 0 #30bfbf, stop: 0.75 #b2ffff, stop: 1 #30bfbf);
        border: 3px inset #00ffff;
        border-radius: 6px;
    }""",
    "P2Win": """QLabel#P2Win {
        background: qlineargradient(x1: 0, y1: 0, x2: 0, y2: 0.75, x3: 0, y3: 1, stop: 0 #bfbf30, stop: 0.75 #ffffb2, stop: 1 #bfbf30);
        border: 3px inset #ffff00;
        border-radius: 6px;
    }""",
}


PLAYER_SETTINGS_PATH = Path(f"{FOLDER}/config/player_settings.json")
if PLAYER_SETTINGS_PATH.is_file():
    PLAYER_SETTINGS = json.loads(PLAYER_SETTINGS_PATH.read_text())
else:
    PLAYER_SETTINGS = {
        "P1": {
            "Blend": "HSL color",
            "Color": (255, 178, 255),
            "Player": "Human",
            "Shape": "Circle",
        },
        "P2": {
            "Blend": "HSV color",
            "Color": (20, 40, 80),
            "Player": "Master AI",
            "Shape": "Circle",
        },
    }


class PieceButton(QPushButton):
    def __init__(self, name: str) -> None:
        super().__init__()
        self.name = name
        self.setObjectName(name)
        self.setFixedSize(100, 100)
        self.setIconSize(QSize(100, 100))


class PieceColorGetter(Box):
    changed = pyqtSignal()

    def __init__(self, color: str, name: str) -> None:
        super().__init__()
        self.widgets = {}
        self.vbox = make_vbox(self)
        self.set_color(color)
        self.init_GUI(name)
        self.init_connections()

    def init_GUI(self, name: str) -> None:
        self.widgets["editor"] = ColorEdit(self.color_text)
        self.widgets["picker"] = ColorPicker()
        self.widgets["button"] = PieceButton(name)
        self.hbox = QHBoxLayout()
        self.hbox.addWidget(self.widgets["editor"])
        self.vbox.addLayout(self.hbox)
        self.vbox.addWidget(self.widgets["button"])

    def init_connections(self) -> None:
        self.widgets["button"].clicked.connect(self._show_color)
        self.widgets["picker"].dialog.accepted.connect(self.pick_color)
        self.widgets["editor"].returnPressed.connect(self.edit_color)

    def set_color(self, text: str) -> None:
        self.color = [int(text[a:b], 16) for a, b in ((1, 3), (3, 5), (5, 7))]

    @property
    def color_text(self) -> str:
        r, g, b = self.color
        return f"#{r:02x}{g:02x}{b:02x}"

    def _show_color(self) -> None:
        self.widgets["picker"].dialog.setCurrentColor(QColor(*self.color))
        self.widgets["picker"].show()

    def pick_color(self) -> None:
        self.color = self.widgets["picker"].dialog.currentColor().getRgb()[:3]
        self.widgets["editor"].setText(self.color_text)
        self.widgets["editor"].color = self.color_text
        self.changed.emit()

    def edit_color(self) -> None:
        text = self.widgets["editor"].text()
        self.widgets["editor"].color = text
        self.set_color(text)
        self.widgets["editor"].clearFocus()
        self.changed.emit()


class Cell(CenterLabel):
    def __init__(self, index: int) -> None:
        super().__init__("", "", 100, 100)
        self.index = index
        self.checked = False
        self.init_connections()

    def init_connections(self) -> None:
        GLOBALS["Game"].clear.connect(self.reset)
        GLOBALS["Game"].restore.connect(self.reset)
        GLOBALS["Game"].change.connect(self._change)

    def change(self) -> None:
        if GLOBALS["animate"]["Board"][0]:
            self._change()

    def reset(self) -> None:
        self.setObjectName("")
        self.clear()
        self.setStyleSheet("")
        self.checked = False

    def _change(self) -> None:
        piece, name = GLOBALS["gamestate"][self.index]
        if piece:
            self.setPixmap(piece.active.qpixmap)
            self.checked = True

        if name:
            self.setObjectName(name)
            self.setStyleSheet(CELLSTYLES[name])
            self.checked = True


class Board(QTableWidget):
    hover_style = """QTableWidget#Game::item:hover {
        background: qlineargradient(x1: 0, y1: 0, x2: 0, y2: 0.75, x3: 0, y3: 1, stop: 0 #a33ba3, stop: 0.75 #ff5cff, stop: 1 #a33ba3);
        border: 3px outset #ff00ff;
        border-radius: 6px;
        margin: 4px;
    }"""

    def __init__(self) -> None:
        self.setup_globals()
        super().__init__()
        self.set_size()
        self.cells = [Cell(i) for i in range(9)]
        for i, cell in enumerate(self.cells):
            self.setCellWidget(*divmod(i, 3), cell)

        self.setSelectionMode(QAbstractItemView.SelectionMode.NoSelection)
        self.setObjectName("Game")
        self.cellPressed.connect(self.onClick)
        self.interactive = True
        self.setFocusPolicy(Qt.FocusPolicy.NoFocus)

    def setup_globals(self) -> None:
        GLOBALS["Game"] = Game()
        GLOBALS["GUIBoard"] = self
        GLOBALS["gamestate"] = [[None, None] for _ in range(9)]

    def set_size(self) -> None:
        self.setColumnCount(3)
        self.setRowCount(3)
        for i in (0, 1, 2):
            self.setColumnWidth(i, 100)
            self.setRowHeight(i, 100)

        self.horizontalHeader().setVisible(False)
        self.verticalHeader().setVisible(False)
        self.setFixedSize(304, 304)
        self.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
        self.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)

    def set_interactive(self, interactive: bool) -> None:
        name = ""
        style = """QTableWidget {
            background: #140a33;
        }
        QTableWidget::item {
            border: 3px groove #632b80;
        }"""
        if interactive:
            name = "Game"
            style += self.hover_style

        self.setObjectName(name)
        self.setStyleSheet(style)
        self.interactive = interactive

    def onClick(self, row: int, col: int) -> None:
        if self.interactive and not (cell := self.cellWidget(row, col)).checked:
            GLOBALS["Game"].chosen = cell.index
            GLOBALS["Game"].human_move()

    def update_images(self) -> None:
        for cell in self.cells:
            if cell.checked:
                cell._change()

    def reset(self) -> None:
        self.clearSelection()
        for cell in self.cells:
            cell.reset()


class GamePlayerBox(Box):
    def __init__(self, number: int) -> None:
        super().__init__()
        self.setObjectName(f"P{number + 1}")
        self.number = number
        self.init_GUI()
        self.update_indicator()
        GLOBALS["Game"].gameover.connect(self.update_scores)
        GLOBALS["Game"].clear.connect(self.update_scores)
        GLOBALS["Game"].orderchange.connect(self.update_indicator)

    def init_GUI(self) -> None:
        self.vbox = make_vbox(self)
        hbox = QHBoxLayout()
        hbox.addWidget(Label(f"Player {self.number + 1}"))
        self.vbox.addLayout(hbox)
        self.add_scores()
        self.indicator = Label("")
        hbox = QHBoxLayout()
        hbox.addWidget(self.indicator)
        hbox1 = QHBoxLayout()
        hbox1.addLayout(self.grid)
        self.vbox.addLayout(hbox)
        self.vbox.addLayout(hbox1)

    def add_scores(self) -> None:
        self.scores = {}
        self.grid = QGridLayout()
        for i, label in enumerate(("Win", "Loss", "Tie")):
            self.grid.addWidget(CenterLabel(label, label, 30, 16), 0, i)
            self.grid.addWidget(score := LongScore("0", label), 1, i)
            self.scores[label] = score

    def update_indicator(self) -> None:
        self.indicator.set_text(f"Current: {self.player}")

    def update_scores(self) -> None:
        for k, v in GLOBALS["stats"][self.player].items():
            self.scores[k].setText(str(v))

    @property
    def player(self) -> str:
        return GLOBALS["game_player_order"][self.number]


class StatsBar(Box):
    def __init__(self) -> None:
        super().__init__()
        self.init_GUI()
        self.init_connections()

    def init_GUI(self) -> None:
        self.setObjectName("Stats")
        self.hbox = make_hbox(self)
        self.quad = {}
        for i, key in enumerate(QUADDRUPLE):
            label = Label("")
            self.quad[key] = label
            self.hbox.addWidget(label)
            if i != 3:
                self.hbox.addStretch()

        self.setFixedHeight(32)
        self.set_game_count()
        self.set_turn_count()
        self.set_active_player()
        self.set_winner()

    def init_connections(self) -> None:
        animation = GLOBALS["Game"]
        animation.gameover.connect(self.set_game_count)
        animation.gameover.connect(self.set_winner)
        animation.playermove.connect(self.set_active_player)
        animation.change.connect(self.set_turn_count)
        animation.clear.connect(self.set_turn_count)
        animation.clear.connect(self.set_active_player)
        animation.clear.connect(self.set_winner)

    def set_game_count(self) -> None:
        self.quad["game"].set_text(f'Game count: {GLOBALS["live_game_count"]}')

    def set_turn_count(self) -> None:
        self.quad["turn"].set_text(f'Turn count: {GLOBALS["live_turn_count"]}')

    def set_active_player(self) -> None:
        self.quad["active"].set_text(f'Active: {GLOBALS["live_active"]}')

    def set_winner(self) -> None:
        self.quad["winner"].set_text(f'Winner: {GLOBALS["live_winner"]}')


class Player(Box):
    def __init__(self, label: str, name: str) -> None:
        super().__init__()
        item = PLAYER_SETTINGS[name]
        GLOBALS[name] = self.piece = Piece(
            item["Color"], item["Blend"], item["Shape"], name
        )
        self.item = item
        self.label = label
        self.changed = False
        self.number = int(name[1]) - 1
        self.name = name
        self.boxname = f"{name}Box"
        GLOBALS[self.boxname] = self
        self.setObjectName(name)
        self.setup_GUI()
        self.set_player_info()

    def set_player_info(self) -> None:
        game = GLOBALS["Game"]
        player = self.item["Player"]
        game.new_players[self.name] = (self.piece, player)
        if player == "Human":
            game.new_match["Human"] = (self.piece, self.name)
        else:
            game.new_match["AI"] = (self.name, player)

        game.new_players["changed"] = True
        game.switch_order()
        game.auto_start()

    def setup_GUI(self) -> None:
        self.vbox = make_vbox(self, 3)
        self.vbox.setAlignment(Qt.AlignmentFlag.AlignHCenter)
        self._setup_playerbox()
        self._setup_player()
        self._setup_shape()
        self._setup_blend()
        self._setup_color()
        self.setFixedSize(150, 330)

    def _setup_playerbox(self) -> None:
        self.playerbox = GamePlayerBox(self.number)
        self.playerbox.setObjectName(self.name)
        self.playerbox.setStyleSheet("QGroupBox { border: 0px;}")
        self.vbox.addWidget(self.playerbox)

    def _setup_player(self) -> None:
        self.player_choice_box = ComboBox(PLAYERS["players"])
        self.player_choice_box.currentTextChanged.connect(self.update_player)
        self.player_choice_box.setCurrentText(self.item["Player"])
        hbox = QHBoxLayout()
        hbox.addWidget(self.player_choice_box)
        self.vbox.addLayout(hbox)

    def _setup_shape(self) -> None:
        self.shape_box = ComboBox(SHAPES)
        self.shape_box.setCurrentText(self.item["Shape"])
        self.shape_box.currentTextChanged.connect(self.pick_piece)
        hbox = QHBoxLayout()
        hbox.addWidget(self.shape_box)
        self.vbox.addLayout(hbox)

    def _setup_blend(self) -> None:
        self.blend_box = ComboBox(BLEND_MODES)
        self.blend_box.setCurrentText(self.item["Blend"])
        self.blend_box.currentTextChanged.connect(self.pick_blend)
        hbox = QHBoxLayout()
        hbox.addWidget(self.blend_box)
        self.vbox.addLayout(hbox)

    def _setup_color(self) -> None:
        r, g, b = self.item["Color"]
        self.colorgetter = PieceColorGetter(f"#{r:02x}{g:02x}{b:02x}", self.name)
        self.colorgetter.vbox.setAlignment(Qt.AlignmentFlag.AlignHCenter)
        self.colorgetter.widgets["button"].setIcon(self.piece.active.qicon)
        self.colorgetter.setObjectName(self.name)
        self.colorgetter.setStyleSheet("QGroupBox { border: 0px;}")
        self.vbox.addWidget(self.colorgetter)
        self.colorgetter.changed.connect(self.set_color)

    def pick_piece(self) -> None:
        self.item["Shape"] = shape = self.shape_box.currentText()
        self.piece.set_active(shape)
        self.update_icons()

    def set_color(self) -> None:
        self.item["Color"] = self.piece.color = self.colorgetter.color
        self.piece.set_color()
        self.update_icons()

    def pick_blend(self) -> None:
        self.item["Blend"] = blend = self.blend_box.currentText()
        self.piece.set_blend(blend)
        self.update_icons()

    def update_icons(self) -> None:
        self.piece.set_icon()
        self.colorgetter.widgets["button"].setIcon(self.piece.active.qicon)
        GLOBALS["GUIBoard"].update_images()

    def update_player(self) -> None:
        self.item["Player"] = self.player_choice_box.currentText()
        self.changed = True
        self.set_player_info()
        GLOBALS["Window"].check_pairing()

logic2.py

from copy import deepcopy
import random
from typing import Callable, Iterable, List, Set, Tuple


SCORES_RL_3 = (10, -10, 10)
FULL3 = (1 << 9) - 1


def pack(line: range, left: int) -> int:
    return (sum(1 << left - i for i in line), tuple(line))


def generate_lines(n: int) -> List[Tuple[int, Tuple[int]]]:
    square = n * n
    lines = []
    left = square - 1
    for i in range(n):
        lines.extend(
            (
                pack(range(i * n, i * n + n), left),
                pack(range(i, square, n), left),
            )
        )

    lines.extend(
        (
            pack(range(0, square, n + 1), left),
            pack(range((m := n - 1), n * m + 1, m), left),
        )
    )
    return lines


LINES_3 = generate_lines(3)


def check_state_3(o: int, x: int) -> Tuple[bool, int]:
    for line, _ in LINES_3:
        if o & line == line:
            return True, 0
        elif x & line == line:
            return True, 1

    return (o | x).bit_count() == 9, 2


def judge_state_3(o: int, x: int) -> Tuple[bool, int, Iterable[int]]:
    for mask, line in LINES_3:
        if o & mask == mask:
            return True, "P1", line
        elif x & mask == mask:
            return True, "P2", line

    return (o | x).bit_count() == 9, None, None

def generate_gaps(lines: List[Tuple[int, Tuple[int]]], l: int):
    k = l * l - 1
    return [
        (sum(1 << k - n for n in line[:i] + line[i + 1 :]), 1 << k - line[i], line[i])
        for _, line in lines
        for i in range(l)
    ]


GAPS_3 = generate_gaps(LINES_3, 3)


def find_gaps_3(board: int, player: int) -> Set[int]:
    return {i for mask, pos, i in GAPS_3 if player & mask == mask and not board & pos}


class Game_Board:
    def __init__(self, order: int) -> None:
        self.width = order**2
        self.left = self.width - 1
        self.choices = list(range(self.width))
        self.state = [0, 0]

    def submit(self, choice: int, player: bool) -> None:
        self.choices.remove(choice)
        self.state[player] |= 1 << self.left - choice

    def reset(self) -> None:
        self.choices = list(range(self.width))
        self.state = [0, 0]


def fill_line_3(state: int, player: int) -> int:
    if gaps := list(find_gaps_3(state, player)):
        return random.choice(gaps)


class Filler:
    def __init__(self, fill: Callable, board: Game_Board, player: bool) -> None:
        self.board = board
        self.fill = fill
        self.player = player

    def get_move(self) -> int:
        o = self.board.state[self.player]
        x = self.board.state[not self.player]
        full = o | x
        for p in (o, x):
            if (move := self.fill(full, p)) is not None:
                return move

        return random.choice(self.board.choices)


class TrueMenace:
    deltas = {"Win": 16, "Tie": 12, "Loss": -8}

    def __init__(
        self,
        states: dict,
        board: Game_Board,
        gaps: Callable,
        order: int,
        fill: bool = False,
        player: bool = False,
        opt: bool = False,
    ) -> None:
        self.boards = []
        self.moves = []
        self.states = deepcopy(states)
        self.board = board
        self.fill = fill
        self.player = player
        self.gaps = gaps
        self.width = order * order
        self.move = None
        self.opt = opt

    def fill_move(self, o: int, x: int, full: int, board: int) -> None:
        if not self.fill:
            return

        for p in (o, x):
            if gaps := list(self.gaps(full, p)):
                self.move = (
                    random.choices(gaps, weights=weights)[0]
                    if (moves := self.states.get(board))
                    and any(weights := [moves.get(g, 0) for g in gaps])
                    else random.choice(gaps)
                )
                break

    def normal_move(self, board: int) -> None:
        self.move = (
            random.choices(list(entry), weights=entry.values())[0]
            if (entry := self.states.get(board))
            else random.choice(self.board.choices)
        )

    def best_gaps(self, moves: dict, gaps: List[int]) -> None:
        score = 0
        self.best = []
        for g in gaps:
            if weight := moves.get(g):
                if weight == score:
                    self.best.append(g)

                elif weight > score:
                    self.best = [g]
                    score = weight

    def fill_move_opt(self, o: int, x: int, full: int, board: int) -> None:
        if not self.fill:
            return

        for p in (o, x):
            if gaps := list(self.gaps(full, p)):
                self.best = []
                if moves := self.states.get(board):
                    self.best_gaps(moves, gaps)

                self.move = random.choice(self.best or gaps)
                self.best = []
                break

    def best_moves(self, moves: dict) -> None:
        score = 0
        self.best = []
        for k, v in moves.items():
            if v and v == score:
                self.best.append(k)
            elif v > score:
                self.best = [k]
                score = v

    def normal_move_opt(self, board: int) -> None:
        if entry := self.states.get(board):
            self.best_moves(entry)
            self.move = random.choice(self.best)
            self.best = []
        else:
            self.move = random.choice(self.board.choices)

    def get_move(self) -> int:
        state = self.board.state
        o = state[self.player]
        x = state[not self.player]
        board = o << self.width | x
        self.boards.append(board)
        [self.fill_move, self.fill_move_opt][self.opt](o, x, o | x, board)
        if self.move is None:
            [self.normal_move, self.normal_move_opt][self.opt](board)

        move = self.move
        self.moves.append(move)
        self.move = None
        return move

    def back_propagate(self, state: str) -> None:
        delta = self.deltas[state]
        if state != "Tie":
            delta <<= len(self.board.choices)

        for board, move in zip(self.boards, self.moves):
            entry = self.states.setdefault(board, {})
            if weight := max(entry.get(move, 0) + delta, 0):
                entry[move] = weight

            if not entry:
                self.states.pop(board)

        self.boards.clear()
        self.moves.clear()

    def fix_mistakes(self, cutoff: int = 9) -> None:
        empty = []
        mask = (1 << self.width) - 1
        for state, entry in self.states.items():
            o = state >> self.width
            x = state & mask
            full = o | x
            gaps = self.gaps(full, o) | self.gaps(full, x)
            for move, weight in list(entry.items()):
                if weight <= cutoff or gaps and move not in gaps:
                    entry.pop(move)

            if not entry:
                empty.append(state)

        for state in empty:
            self.states.pop(state)

gamecontrol2.py

import json
import pickle
from basics2 import MessageBox
from itertools import cycle
from logic2 import *
from pathlib import Path
from PyQt6.QtCore import QThread, pyqtSignal
from PyQt6.QtTest import QTest
from shared2 import FOLDER, GLOBALS, PLAYER_NAMES

GLOBALS["GameBoard"] = Game_Board(3)
STATSPATH = Path(f"{FOLDER}/Data/stats.json")
STATES_P1_EASY = pickle.loads(Path(f"{FOLDER}/Data/STATES_3_P1_Easy.pkl").read_bytes())
STATES_P2_EASY = pickle.loads(Path(f"{FOLDER}/Data/STATES_3_P2_Easy.pkl").read_bytes())
STATES_P1_HARD = pickle.loads(Path(f"{FOLDER}/Data/STATES_3_P1_Hard.pkl").read_bytes())
STATES_P2_HARD = pickle.loads(Path(f"{FOLDER}/Data/STATES_3_P2_Hard.pkl").read_bytes())


AIPLAYERS = {
    "Novice AI": {
        "P1": Filler(fill_line_3, GLOBALS["GameBoard"], 0),
        "P2": Filler(fill_line_3, GLOBALS["GameBoard"], 1),
    },
    "Adept AI": {
        "P1": TrueMenace(STATES_P1_EASY, GLOBALS["GameBoard"], find_gaps_3, 3, 1, 0, 0),
        "P2": TrueMenace(STATES_P2_EASY, GLOBALS["GameBoard"], find_gaps_3, 3, 1, 1, 0),
    },
    "Adept AI+": {
        "P1": TrueMenace(STATES_P1_EASY, GLOBALS["GameBoard"], find_gaps_3, 3, 1, 0, 1),
        "P2": TrueMenace(STATES_P2_EASY, GLOBALS["GameBoard"], find_gaps_3, 3, 1, 1, 1),
    },
    "Master AI": {
        "P1": TrueMenace(STATES_P1_HARD, GLOBALS["GameBoard"], find_gaps_3, 3, 1, 0, 0),
        "P2": TrueMenace(STATES_P2_HARD, GLOBALS["GameBoard"], find_gaps_3, 3, 1, 1, 0),
    },
    "Master AI+": {
        "P1": TrueMenace(STATES_P1_HARD, GLOBALS["GameBoard"], find_gaps_3, 3, 1, 0, 1),
        "P2": TrueMenace(STATES_P2_HARD, GLOBALS["GameBoard"], find_gaps_3, 3, 1, 1, 1),
    },
}


class Game(QThread):
    gameover = pyqtSignal()
    playermove = pyqtSignal()
    change = pyqtSignal()
    clear = pyqtSignal()
    restore = pyqtSignal()
    orderchange = pyqtSignal()
    other = {"P1": "P2", "P2": "P1"}

    def __init__(self) -> None:
        super().__init__()
        self.load_stats()
        self.gamestarted = False
        self.auto = False
        self.chosen = None
        self.players = {"P1": [None, None], "P2": [None, None]}
        self.match = {}
        self.new_players = {"P1": [None, None], "P2": [None, None], "changed": False}
        self.new_match = {}
        self.interactive = True
        self.over = False
        self.finished.connect(self.reset)
        self.messages = {
            "Win": MessageBox("Win", "You won!", "win_emoji", "Yes!!!"),
            "Loss": MessageBox("Loss", "You lost...", "loss_emoji", "NOO!!!"),
            "Tie": MessageBox("Tie", "It's a draw.", "tie_emoji", "Okay..."),
        }

    def make_move(self, player: str, piece: int) -> None:
        self.gamestarted = True
        icon, name = self.players[player]
        GLOBALS["live_active"] = name
        self.playermove.emit()
        move = AIPLAYERS[name][player].get_move()
        GLOBALS["GameBoard"].submit(move, piece)
        GLOBALS["gamestate"][move] = (icon, player)
        GLOBALS["live_turn_count"] += 1
        self.change.emit()

    def nought(self) -> None:
        self.make_move("P1", 0)

    def cross(self) -> None:
        self.make_move("P2", 1)

    def run(self) -> None:
        self.interactive = False
        self.auto = True
        while GLOBALS["rungame"]:
            self.over = False
            self.rungame()
            QTest.qWait(125)

        self.interactive = True
        self.quit()

    def rungame(self) -> None:
        turns = cycle([self.nought, self.cross])
        for _ in range(9):
            next(turns)()
            QTest.qWait(125)
            self.judge()
            if self.over:
                break

    def counter_human(self) -> None:
        self.auto = False
        m = self.match["AI"][0]
        self.make_move(m, m == "P2")
        GLOBALS["live_active"] = "Human"
        self.playermove.emit()
        self.judge()

    def human_move(self) -> None:
        self.auto = False
        self.gamestarted = True
        GLOBALS["gamestate"][self.chosen] = m = self.match["Human"]
        GLOBALS["GameBoard"].submit(self.chosen, m[1] == "P2")
        GLOBALS["live_turn_count"] += 1
        self.change.emit()
        self.judge(True)

    def judge(self, move: bool = False) -> None:
        state, winner, line = judge_state_3(*GLOBALS["GameBoard"].state)
        if winner:
            self.process_win(winner, line)
        elif state:
            self.process_tie()
        elif move:
            self.counter_human()

    def process_tie(self) -> None:
        if GLOBALS["popup"] and self.match:
            self.messages["Tie"].show()
            while self.messages["Tie"].isVisible():
                QTest.qWait(42)

        for v in self.players.values():
            self.stats[v[1]]["Tie"] += 1

        self.reset()

    def process_win(self, winner: str, line: range) -> None:
        if self.match:
            self.process_match_win(winner, line)
        else:
            win_name = self.players[winner][1]
            loser = self.other[winner]
            loss_name = self.players[loser][1]
            self.show_winner(win_name, winner, loss_name, line)

    def process_match_win(self, winner: str, line: range) -> None:
        if self.players[winner][1] != "Human":
            win_number, win_name = self.match["AI"]
            loss_name = "Human"
        else:
            win_name = "Human"
            win_number = self.match["Human"][1]
            loss_name = self.match["AI"][1]

        self.show_winner(win_name, win_number, loss_name, line)

    def show_winner(
        self, win_name: str, win_number: str, loss_name: str, line: range
    ) -> None:
        GLOBALS["live_winner"] = win_name
        self.stats[win_name]["Win"] += 1
        self.stats[loss_name]["Loss"] += 1
        for i in line:
            GLOBALS["gamestate"][i] = (self.players[win_number][0], f"{win_number}Win")

        self.change.emit()
        self.gameover.emit()
        self.post_process(win_name)
        QTest.qWait(125)
        self.reset()

    def post_process(self, win_name: str) -> None:
        if GLOBALS["popup"] and self.match:
            state = "Win" if win_name == "Human" else "Loss"
            self.messages[state].show()
            while self.messages[state].isVisible():
                QTest.qWait(42)

    def auto_start(self) -> None:
        if (ai := self.match.get("AI")) and ai[0] == "P1" and not self.gamestarted:
            self.counter_human()
            GLOBALS["POPUP"].setDisabled(False)

    def reset(self) -> None:
        self.over = True
        GLOBALS["gamestate"] = [[None, None] for _ in range(9)]
        GLOBALS["GameBoard"].reset()
        GLOBALS["live_game_count"] += 1
        GLOBALS["live_turn_count"] = 0
        GLOBALS["live_winner"] = "null"
        GLOBALS["live_active"] = self.players["P1"][1]
        QTest.qWait(125)
        self.clear.emit()
        self.gameover.emit()
        if self.interactive:
            self.gamestarted = False
            self.switch_order()
            self.auto_start()

    def switch_order(self) -> None:
        if self.new_players["changed"] and not self.gamestarted:
            self.players = self.new_players.copy()
            self.players.pop("changed")
            GLOBALS["game_player_order"] = players = [
                p[1] for p in self.players.values()
            ]
            self.set_match(players)
            if self.match:
                GLOBALS["rungamebutton"].setDisabled(True)
            else:
                GLOBALS["rungamebutton"].setDisabled(False)

            self.new_players["changed"] = False
            self.gameover.emit()
            self.playermove.emit()
            self.orderchange.emit()

    def set_match(self, players: list) -> None:
        if not (interactive := "Human" in players):
            self.new_match.clear()
            GLOBALS["popup"] = False
            GLOBALS["POPUP"].setDisabled(True)
            GLOBALS["POPUP"].setChecked(False)
        elif "AI" not in self.new_match:
            human = self.new_match["Human"]
            self.new_match["AI"] = (
                (name := self.other[human[1]]),
                self.new_players[name][1],
            )
            GLOBALS["POPUP"].setDisabled(False)
        GLOBALS["GUIBoard"].set_interactive(interactive)
        self.match = self.new_match.copy()

    def load_stats(self) -> None:
        if STATSPATH.is_file():
            self.stats = json.loads(STATSPATH.read_text())
        else:
            self.stats = {
                k: {state: 0 for state in ("Win", "Loss", "Tie")} for k in PLAYER_NAMES
            }
            STATSPATH.write_text(json.dumps(self.stats, indent=4))

        GLOBALS["stats"] = self.stats

train_ai.py

import pickle
import time
from itertools import cycle
from logic2 import *
from pathlib import Path
from typing import List, Tuple


def evaluate_end_states_3(
    board: int, states: dict, turns: int
) -> Tuple[bool, int] | Tuple[bool, int, int]:
    o = board >> 9
    x = board & FULL3
    if entry := states.get(o << 9 | x):
        return True, entry["score"]

    over, winner = check_state_3(o, x)
    if over:
        score = SCORES_RL_3[winner]
        if winner < 2:
            score <<= turns

        states[board] = {"score": score}
        return True, score

    return False, o, x


def analyze_states_3(
    board: int,
    states: dict,
    move: bool,
    moves: List[int],
    min_turns: int,
) -> int:
    if (result := evaluate_end_states_3(board, states, turns := len(moves)))[0]:
        return result[1]

    if turns < min_turns:
        return 0

    return analyze_states_3_worker(board, *result[1:], states, move, moves, min_turns)


def analyze_states_3_worker(
    board: List[int],
    o: int,
    x: int,
    states: dict,
    move: bool,
    moves: List[int],
    min_turns: int,
) -> int:
    full = o | x
    gaps = find_gaps_3(full, o) | find_gaps_3(full, x)
    left, new = (17, False) if move else (8, True)
    weights = {
        n: analyze_states_3(
            board | 1 << left - n, states, new, moves[:i] + moves[i + 1 :], min_turns
        )
        for i, n in enumerate(moves)
        if not gaps or n in gaps
    }
    states[board] = {"weights": weights, "score": (score := sum(weights.values()))}
    return score


def fix_states(states: dict) -> None:
    topop = []
    for board, entry in states.items():
        if (weights := entry.get("weights")) and (
            weights := {move: weight for move, weight in weights.items() if weight > 0}
        ):
            states[board] = weights
        else:
            topop.append(board)

    for board in topop:
        states.pop(board)


class Trainer:
    def __init__(
        self, feedback: bool = True, opt: bool = False, smart_enemy: bool = True
    ) -> None:
        self.turns = cycle([self.nought, self.cross])
        stats = {"games": 0, "O": 0, "X": 0}
        self.feedback = feedback
        self.stats = [stats.copy() for _ in range(4)]
        self.board = Game_Board(3)
        self.smart_enemy = smart_enemy
        self.setup_players(opt)
        self.setup_opponents()
        self.index = 0

    def setup_players(self, opt: bool) -> None:
        self.players = [
            TrueMenace(STATES_3_P1, self.board, find_gaps_3, 3, opt=opt),
            TrueMenace(STATES_3_P2, self.board, find_gaps_3, 3, opt=opt),
            TrueMenace(STATES_3_P1, self.board, find_gaps_3, 3, fill=True, opt=opt),
            TrueMenace(STATES_3_P2, self.board, find_gaps_3, 3, fill=True, opt=opt),
        ]

    def setup_opponents(self) -> None:
        self.opponents = [
            TrueMenace(STATES_3_P2, self.board, find_gaps_3, 3, fill=True, player=1),
            TrueMenace(STATES_3_P1, self.board, find_gaps_3, 3, fill=True, player=1),
            TrueMenace(STATES_3_P2, self.board, find_gaps_3, 3, fill=True, player=1),
            TrueMenace(STATES_3_P1, self.board, find_gaps_3, 3, fill=True, player=1),
        ]

    def nought(self) -> None:
        self.board.submit(self.players[self.index].get_move(), 0)

    def cross(self) -> None:
        if self.smart_enemy:
            self.board.submit(self.opponents[self.index].get_move(), 1)
        else:
            state = self.board.state[0] | self.board.state[1]
            for p in (1, 0):
                if (pos := fill_line_3(state, p)) is not None:
                    self.board.submit(pos, 1)
                    break

            else:
                self.board.submit(random.choice(self.board.choices), 1)

    def game(self) -> None:
        for _ in range(9):
            next(self.turns)()
            _, winner = check_state_3(self.board.state[0], self.board.state[1])
            if winner != 2:
                self.stats[self.index][("O", "X")[winner]] += 1
                if self.feedback:
                    o, x = [("Win", "Loss"), ("Loss", "Win")][winner]
                    self.players[self.index].back_propagate(o)
                    self.opponents[self.index].back_propagate(x)
                break
        else:
            if self.feedback:
                self.players[self.index].back_propagate("Tie")
                self.opponents[self.index].back_propagate("Tie")

        self.stats[self.index]["games"] += 1
        self.board.reset()

    def train1(self, n: int, cheat: bool = False) -> None:
        self.index = 0 + 2 * cheat
        for _ in range(n):
            self.turns = cycle([self.nought, self.cross])
            self.game()

    def train2(self, n: int, cheat: bool = False) -> None:
        self.index = 1 + 2 * cheat
        for _ in range(n):
            self.turns = cycle([self.cross, self.nought])
            self.game()


if __name__ == "__main__":
    STATES_3_P1 = {}
    analyze_states_3(0, STATES_3_P1, 1, list(range(9)), 0)
    STATES_3_P2 = {}
    analyze_states_3(0, STATES_3_P2, 0, list(range(9)), 0)
    fix_states(STATES_3_P1)
    fix_states(STATES_3_P2)
    choices = {"yes": True, "no": False, "y": True, "n": False, "q": False}
    commit = ""
    while not choices.get(commit):
        trainer = Trainer()
        start = time.time()
        trainer.train1(1048576, 1)
        trainer.train2(1048576, 1)
        duration = time.time() - start
        print(duration)
        print(duration / 2)
        print(trainer.stats)
        for player in trainer.players:
            player.fix_mistakes()

        commit = ""
        while commit not in choices:
            commit = input("Are you satisfied with the results? ").lower()

        if commit == "q":
            quit()

    FOLDER = str(Path(__file__).parent).replace("\\", "/")
    Path(f"{FOLDER}/Data/STATES_3_P1_Easy.pkl").write_bytes(
        pickle.dumps(STATES_3_P1, protocol=pickle.HIGHEST_PROTOCOL)
    )
    Path(f"{FOLDER}/Data/STATES_3_P2_Easy.pkl").write_bytes(
        pickle.dumps(STATES_3_P2, protocol=pickle.HIGHEST_PROTOCOL)
    )
    Path(f"{FOLDER}/Data/STATES_3_P1_Hard.pkl").write_bytes(
        pickle.dumps(trainer.players[2].states, protocol=pickle.HIGHEST_PROTOCOL)
    )
    Path(f"{FOLDER}/Data/STATES_3_P2_Hard.pkl").write_bytes(
        pickle.dumps(trainer.players[3].states, protocol=pickle.HIGHEST_PROTOCOL)
    )

shared2.py

import blend_modes
import numpy as np
from itertools import cycle
from pathlib import Path
from PIL import Image


SWITCH = cycle(["Stop", "Run"])
FOLDER = str(Path(__file__).parent).replace("\\", "/")


BOX = ("background", "bordercolor", "borderstyle")


SHAPES = (
    "Circle",
    "Triangle 0",
    "Triangle 1",
    "Triangle 2",
    "Triangle 3",
    "Square",
    "Diamond",
    "Pentagon 0",
    "Pentagon 1",
    "Pentagon 2",
    "Pentagon 3",
    "Hexagon 0",
    "Hexagon 1",
    "Ring",
    "Rhombus 0",
    "Rhombus 1",
    "Cross 0",
    "Cross 1",
    "Pentagram 0",
    "Pentagram 1",
    "Pentagram 2",
    "Pentagram 3",
    "Hexagram 0",
    "Hexagram 1",
)

BLEND_MODES = {
    "Lighten": blend_modes.blend_lighten,
    "Screen": blend_modes.blend_screen,
    "Color dodge": blend_modes.blend_color_dodge,
    "Linear dodge": blend_modes.blend_linear_dodge,
    "Darken": blend_modes.blend_darken,
    "Multiply": blend_modes.blend_multiply,
    "Color burn": blend_modes.blend_color_burn,
    "Linear burn": blend_modes.blend_linear_burn,
    "Overlay": blend_modes.blend_overlay,
    "Soft light": blend_modes.blend_soft_light,
    "Hard light": blend_modes.blend_hard_light,
    "Vivid light": blend_modes.blend_vivid_light,
    "Linear light": blend_modes.blend_linear_light,
    "Pin light": blend_modes.blend_pin_light,
    "Reflect": blend_modes.blend_reflect,
    "Difference": blend_modes.blend_difference,
    "Exclusion": blend_modes.blend_exclusion,
    "Subtract": blend_modes.blend_subtract,
    "Grain extract": blend_modes.blend_grain_extract,
    "Grain merge": blend_modes.blend_grain_merge,
    "Divide": blend_modes.blend_divide,
    "HSV color": blend_modes.blend_HSV_Color,
    "HSL color": blend_modes.blend_HSL_Color,
    "Color lux": blend_modes.blend_color_lux,
    "Color nox": blend_modes.blend_color_nox,
    "LCh D65": blend_modes.blend_color_LCh_D65,
    "LCh D50": blend_modes.blend_color_LCh_D50,
}


GLOBALS = {
    "pause": False,
    "run": False,
    "rungame": False,
    "popup": False,
    "live_active": "Human",
    "live_game_count": 0,
    "live_turn_count": 0,
    "live_winner": "null",
    "order": ("cross", "nought"),
    "game_player_order": ["Human", "Master AI"],
    "new_order": ["PX", "PO"],
    "orderchanged": False,
}


X = bytearray(np.array(Image.open(f"{FOLDER}/icons/X.png")))
O = bytearray(np.array(Image.open(f"{FOLDER}/icons/O.png")))
RUN = cycle(["Stop", "Start"])
PAUSE = cycle(["Resume", "Pause"])
SEXDECIM = ["High"] + ["Mock"] * 15
SEXTET = ("Cell", "Hover", "P1", "P2", "P1Win", "P2Win")

CONTROL = (
    ("Default", "restore_default"),
    ("OK", "apply_style"),
    ("Cancel", "revert_style"),
)
QUADDRUPLE = (
    "game",
    "turn",
    "active",
    "winner",
)
PLAYER_NAMES = (
    "Human",
    "Novice AI",
    "Adept AI",
    "Adept AI+",
    "Master AI",
    "Master AI+",
)

PLAYERS = {
    "players": PLAYER_NAMES,
    "opponents": {
        k: PLAYER_NAMES[:i] + PLAYER_NAMES[i + 1 :] for i, k in enumerate(PLAYER_NAMES)
    },
}


CELLNAMES = ("Cell", "Hover", "P1", "P2", "P1Win", "P2Win")

The above code isn't complete, you cannot run it, since the stylesheet and the code for customization of the pieces are missing. Also the icons aren't included, obviously. But everything related to GUI and the game proper is included. I will post the code for customization later in another question, because the question is limited to 65536 characters.

I have packaged everything and uploaded to Google drive, and you can download the package to run the game, the link. When I said Master AI(+) is unbeatable, I didn't exaggerate, it is by design unbeatable, and I have achieved it. Because I used pickle you may have to run train_ai once first to generate the necessary data files before running the game, the training takes about 8 minutes. The game is run by running main2.py.

How can my code be improved?

\$\endgroup\$
1
  • 1
    \$\begingroup\$ Regarding unbeatable AI: I wrote one of those back in the 1980’s when I was maybe 13 or 14, in BASIC, using only a few hard-codes rules. This game is so simple, you can write out all possible games by hand in little time. Using ML for this seems rather wasteful. \$\endgroup\$ Commented Nov 14, 2023 at 0:22

1 Answer 1

2
\$\begingroup\$

This is a tiny review that focuses only on logic2.py and train_ai.py.

  • There are a lot of ints being passed around, undergoing a variety of bitwise operations, and representing many things, e.g. a player's moves, the entire board, etc. -- but unfortunately there is no documentation explaining the purpose/bit structure of each.
  • There isn't really a good encapsulation of the game board logic by any one entity; every function is just expected to know what to do given these raw integers board: int, full: int, o: int, or x: int.
  • In TrueMenace there are a lot of methods that seem to coordinate carefully with each other by adding values to the self.best list, picking a value from it, and then resetting it back to an empty list afterwards. This is hard to follow and prone to error. It would be easier to understand the program flow if the method returned the list directly to the caller. For example, in normal_move_opt, where self.best is currently inspected after best_moves is called,
    # Before
    if entry := self.states.get(board):
        self.best_moves(entry)
        self.move = random.choice(self.best)
        self.best = []
    
    If best_moves returned the list directly, this would allow us to refactor the above to:
    # After
    if entry := self.states.get(board):
        self.move = random.choice(self.best_moves(entry))
    
  • There are quite a few type errors to fix; running mypy --strict on train_ai.py and logic2.py found 43 and 18 errors, respectively.
  • analyze_states_3_worker uses the magic numbers 17 and 8, which should ideally have constants defined for them instead.
  • You can make the code more self-documenting by turning the Tuples in your return types (Tuple[bool, int] | Tuple[bool, int, int] , Tuple[bool, int], and Tuple[bool, int, Iterable[int]]) into a type with labeled fields like a dataclass.
  • Modeling the tic-tac-board as an integer and doing all board mutations and analysis with bitwise operations adds a significant amount of cognitive complexity to the code, when a simpler model would have likely sufficed. I recommend simplifying to improve readability and maintainability. If performance is a concern, you can always profile each one to get a sense of how good each implementation's performance is relative to its cognitive complexity.
\$\endgroup\$

Not the answer you're looking for? Browse other questions tagged or ask your own question.