From 6d7b48870b84cfb4eefcfcedd41cdf996e896433 Mon Sep 17 00:00:00 2001 From: SN <2005kan@mail.ru> Date: Sat, 29 Mar 2025 14:49:58 +0500 Subject: [PATCH 1/3] v1 Delete dots/leaderboard.txt v2 v3 v4 v5 v6 v7 v8 v8 --- .idea/.gitignore | 5 + .pre-commit-config.yaml | 30 ++ .venv/.gitignore | 2 + README.md | 88 +++++- dots/constants.py | 10 + dots/dots_game.py | 490 ++++++++++++++++++++++++++++++++ dots/game_logic.py | 247 ++++++++++++++++ dots/help.py | 45 +++ dots/ikon.png | Bin 0 -> 74734 bytes dots/interface.py | 106 +++++++ dots/leaderboard.py | 66 +++++ dots/main.py | 27 ++ dots/test_dots_game.py | 359 +++++++++++++++++++++++ dots/test_game_logic.py | 227 +++++++++++++++ dots/test_leaderboard.py | 174 ++++++++++++ mypy.ini | 27 ++ poetry.lock | 598 +++++++++++++++++++++++++++++++++++++++ pyproject.toml | 44 +++ 18 files changed, 2544 insertions(+), 1 deletion(-) create mode 100644 .idea/.gitignore create mode 100644 .pre-commit-config.yaml create mode 100644 .venv/.gitignore create mode 100644 dots/constants.py create mode 100644 dots/dots_game.py create mode 100644 dots/game_logic.py create mode 100644 dots/help.py create mode 100644 dots/ikon.png create mode 100644 dots/interface.py create mode 100644 dots/leaderboard.py create mode 100644 dots/main.py create mode 100644 dots/test_dots_game.py create mode 100644 dots/test_game_logic.py create mode 100644 dots/test_leaderboard.py create mode 100644 mypy.ini create mode 100644 poetry.lock create mode 100644 pyproject.toml diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..e96aed1 --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,5 @@ +# Default ignored files +/shelf/ +/workspace.xml +.venv/ +.idea/ diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..2ab8571 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,30 @@ +repos: + - repo: https://github.com/PyCQA/isort + rev: 5.12.0 + hooks: + - id: isort + + - repo: https://github.com/pre-commit/mirrors-mypy + rev: v1.6.1 + hooks: + - id: mypy + + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v4.4.0 + hooks: + - id: trailing-whitespace + - id: end-of-file-fixer + - id: check-yaml + + - repo: https://github.com/psf/black + rev: 23.10.1 + hooks: + - id: black + args: + - "--config=pyproject.toml" + + - repo: https://github.com/PyCQA/flake8 + rev: 6.1.0 + hooks: + - id: flake8 + args: [--max-line-length=120] diff --git a/.venv/.gitignore b/.venv/.gitignore new file mode 100644 index 0000000..f50e9d4 --- /dev/null +++ b/.venv/.gitignore @@ -0,0 +1,2 @@ +# Created by venv; see https://docs.python.org/3/library/venv.html +.idea diff --git a/README.md b/README.md index 29a52c7..fd0a016 100644 --- a/README.md +++ b/README.md @@ -1 +1,87 @@ -# Dots_Game \ No newline at end of file +# 🎮 Точки - классическая стратегическая игра + +## 🚀 Установка и запуск + +## 📋 Предварительные требования +- Python 3.9+ +- [Poetry](https://python-poetry.org/docs/#installation) (менеджер зависимостей) + +## ⚙️ Установка +```bash +git clone https://github.com/ваш-репозиторий/Dots_Game.git +cd Dots_Game +poetry install +``` + +## 🎮 Запуск игры +```bash +python main.py +``` + +## Для справки: + +```bash +poetry run python main.py help +``` + +## 🌟 Особенности + +- 🎯 Три режима игры: PvP, против простого ИИ и "умного" компьютера +- 🖌️ Гибкая настройка размера поля (до 30×33) +- 📊 Система рейтинга с JSON-таблицей лидеров +- 🎨 Интуитивный графический интерфейс на Tkinter +- ⚡ Оптимизированная игровая логика +- 🔍 Встроенная система помощи + +## 🎮 Режимы игры + +| Режим | Описание | +|-----------------|----------------------------------------------------| +| Игрок vs Игрок | Классический вариант для двух игроков | +| Простой ИИ | Компьютер делает случайные ходы | +| Умный ИИ | Компьютер использует стратегию для победы | + +## 🏆 Правила игры +- Игроки ходят по очереди, ставя точки на пересечения линий +- Цель - окружить точки противника непрерывной цепью +- Окруженные точки становятся вашими +- Игра продолжается, пока есть свободные места или пока игроки не завершат игру +- Побеждает тот, кто захватил больше точек! + +## 🛠️ Структура проекта +Dots_Game/ +├── main.py # Точка входа +├── dots_game.py # Основной игровой класс +├── game_logic.py # Логика игры и ИИ +├── leaderboard.py # Работа с рейтингом +├── interface.py # GUI элементы +├── constants.py # Константы игры +├── help.py # Система помощи +├── leaderboard.json # Таблица лидеров (JSON) +├── lkon.png # Скриншот игры +├── pre-commit-config.yaml # Настройки pre-commit +├── mypy.ini # Конфигурация mypy +├── poetry.lock # Зависимости Poetry +├── pyproject.toml # Конфигурация проекта +└── README.md # Документация +##📊 Таблица лидеров + +Игра автоматически сохраняет результаты в JSON-файл leaderboard.json. +Для просмотра рейтинга используйте соответствующую кнопку в интерфейсе. + +## 👥 Авторы +Самородова Александра + +Назарова Инесса + +Checklist: +- [x] Добавлен `.gitignore`. Убедитесь, что там есть `.venv` и `.idea` +- [x] Создано виртуальное окружение +- [x] Есть файл `requirements.txt` или `pyproject.toml`. Исключения: если у вас нет внешних зависимостей. +- [x] Настроены линтеры: `mypy` и `flake8` +- [x] Настроены форматтеры: `isort` и `black` +- [x] Написаны тесты +- [x] Написана документация к каждому методу, классу и функции +- [x] Написан красивый `README.md` (для форматирования можно использовать markdown), где есть информация о том, как проект установить и запустить, что он делает и умеет, какие фунции там есть +- [x] (Для консольных утилит) написан help +- [x] (Опционально) Есть прекоммит diff --git a/dots/constants.py b/dots/constants.py new file mode 100644 index 0000000..af134f3 --- /dev/null +++ b/dots/constants.py @@ -0,0 +1,10 @@ +from typing import Final + + +CELL_SIZE: Final[int] = 20 +WIDTH: Final[int] = 1200 +HEIGHT: Final[int] = 750 +OFFSET_X: Final[int] = 20 +OFFSET_Y: Final[int] = 50 +BLUE_TEAM: Final[int] = 1 +RED_TEAM: Final[int] = 2 diff --git a/dots/dots_game.py b/dots/dots_game.py new file mode 100644 index 0000000..f1a4fb2 --- /dev/null +++ b/dots/dots_game.py @@ -0,0 +1,490 @@ +from __future__ import annotations + +from enum import Enum +from tkinter import BOTH, Button, Canvas, Entry, Label, Tk, Toplevel, font, messagebox +from tkinter.ttk import Treeview +from typing import Any, Callable, List, Optional + +from constants import BLUE_TEAM, CELL_SIZE, OFFSET_X, OFFSET_Y, RED_TEAM +from game_logic import GameLogic +from interface import create_leaderboard, create_rules, create_scoreboard +from leaderboard import load_leaderboard, update_leaderboard + + +class GameResult(Enum): + """ + Перечисление возможных результатов игры. + """ + + BLUE_WIN = 1 + RED_WIN = 2 + DRAW = 3 + + @property + def winner_text(self) -> str: + texts = {GameResult.BLUE_WIN: "Синий игрок", GameResult.RED_WIN: "Красный игрок", GameResult.DRAW: "Ничья"} + return texts[self] + + @property + def color(self) -> str: + colors = {GameResult.BLUE_WIN: "darkblue", GameResult.RED_WIN: "darkred", GameResult.DRAW: "black"} + return colors[self] + + +def determine_result(blue_score: int, red_score: int) -> GameResult: + """ + Определяет результат игры на основе количества очков игроков. + """ + if blue_score > red_score: + return GameResult.BLUE_WIN + if red_score > blue_score: + return GameResult.RED_WIN + return GameResult.DRAW + + +class DotsGame: + def __init__(self, root: Tk) -> None: + """ + Инициализирует игру. + """ + self._root: Tk = root + self._canvas: Canvas = Canvas(root, bg="white") + self._canvas.pack(fill=BOTH, expand=True) + + self._grid_width: int = 30 + self._grid_height: int = 33 + self._mode: str = "PVP" + self._game_logic: Optional[GameLogic] = None + self._leaderboard: Optional[List[Any]] = None + self._leaderboard_tree: Optional[Treeview] = None + self._scoreboard: Optional[Treeview] = None + self._end_button: Optional[Button] = None + self._entry1: Optional[Entry] = None + self._entry2: Optional[Entry] = None + + def start(self) -> None: + """ + Запускает игру, инициализируя пользовательский интерфейс и игровое поле. + """ + self._setup_ui() + self._create_grid() + + def _setup_ui(self) -> None: + """ + Настраивает пользовательский интерфейс. + """ + self._bind_events() + self._init_interface_elements() + self._init_leaderboard_data() + + def _bind_events(self) -> None: + """ + Привязывает обработчики событий к элементам интерфейса. + """ + self._canvas.bind("", lambda e: self._create_grid()) + self._canvas.bind("", self._on_canvas_click) + + def _init_interface_elements(self) -> None: + """ + Инициализирует все элементы интерфейса. + """ + self._leaderboard_tree = create_leaderboard(self._root) + self._scoreboard = create_scoreboard(self._root) + create_rules(self._root) + self._create_mode_buttons() + self._create_size_selection() + self._create_end_game_button() + + def _init_leaderboard_data(self) -> None: + """ + Загружает данные таблицы лидеров. + """ + self._leaderboard = load_leaderboard() + self._update_leaderboard_display() + + def _update_leaderboard_display(self) -> None: + """ + Обновляет отображение таблицы лидеров. + """ + if self._leaderboard_tree is None or self._leaderboard is None: + return + + for row in self._leaderboard_tree.get_children(): + self._leaderboard_tree.delete(row) + for entry in self._leaderboard: + self._leaderboard_tree.insert("", "end", values=(entry.place, entry.name, entry.score)) + + def _create_end_game_button(self) -> None: + """ + Создает кнопку для досрочного завершения игры. + """ + self._end_button = Button( + self._root, + text="Завершить игру", + command=self._end_game, + fg="white", + bg="red", + font=("Arial", 11, "bold"), + width=17, + height=1, + ) + self._end_button.place(x=700, y=60) + + def _end_game(self) -> None: + """ + Завершает текущую игру. + """ + if self._game_logic is None: + return + + scores = self._game_logic.scores + blue_score = scores[BLUE_TEAM] + red_score = scores[RED_TEAM] + + result = determine_result(blue_score, red_score) + board_full = hasattr(self._game_logic, "is_board_full") and self._game_logic.is_board_full() + + message = ( + f"{'Доска заполнена' if board_full else 'Игра завершена досрочно'}! " f"Игра окончена! {result.winner_text}" + ) + winning_score_mapping = { + GameResult.BLUE_WIN: blue_score, + GameResult.RED_WIN: red_score, + GameResult.DRAW: max(blue_score, red_score), + } + winning_score = winning_score_mapping[result] + + if result != GameResult.DRAW: + self._show_winner_dialog(result, winning_score, message) + else: + self._show_game_over_message(message, result.color) + + def _show_winner_dialog(self, result: GameResult, winning_score: int, message: str) -> None: + """ + Показывает диалог победителя. + """ + name_window = Toplevel(master=self._root) + name_window.title("Поздравляем!") + name_window.geometry("400x200") + name_window.resizable(width=False, height=False) + + Label( + master=name_window, + text=f"Вы набрали {winning_score} очков!\nВведите ваше имя:", + font=("Arial", 12), + wraplength=380, + ).pack(pady=10) + + name_entry = Entry(master=name_window, font=("Arial", 12), width=30) + name_entry.pack(pady=5) + + def save_and_close() -> None: + player_name = name_entry.get().strip() + if player_name: + updated = update_leaderboard(player_name, winning_score) + if updated: + self._leaderboard = load_leaderboard() + self._update_leaderboard_display() + messagebox.showinfo("Успех", "Вы попали в таблицу лидеров!") + else: + messagebox.showinfo("Сожалеем", "Ваш результат не попал в топ-3.") + name_window.destroy() + self._show_game_over_message(message, result.color) + else: + messagebox.showerror("Ошибка", "Пожалуйста, введите имя!") + + Button(name_window, text="Сохранить", command=save_and_close, font=("Arial", 12)).pack(pady=10) + + def _show_game_over_message(self, message: str, color: str) -> None: + """ + Показывает окно с результатом игры. + """ + game_over_window = Toplevel(self._root) + game_over_window.title("Игра окончена") + game_over_window.geometry("400x150") + game_over_window.resizable(False, False) + + Label( + game_over_window, + text=message, + font=("Arial", 14, "bold"), + fg="white", + bg=color, + wraplength=380, + ).pack(expand=True, fill=BOTH) + + Button( + game_over_window, + text="OK", + command=game_over_window.destroy, + font=("Arial", 12), + ).pack(pady=10) + + self._game_logic = None + + def _create_grid(self) -> None: + """ + Отрисовывает игровое поле. + """ + self._canvas.delete("grid_line") + + start_x = OFFSET_X + start_y = OFFSET_Y + end_x = self._grid_width * CELL_SIZE + OFFSET_X + 1 + end_y = self._grid_height * CELL_SIZE + OFFSET_Y + 1 + + for coord in range(start_x, end_x, CELL_SIZE): + self._canvas.create_line(coord, start_y, coord, end_y, tags="grid_line", fill="lightblue") + for coord in range(start_y, end_y, CELL_SIZE): + self._canvas.create_line(start_x, coord, end_x, coord, tags="grid_line", fill="lightblue") + + def _on_canvas_click(self, event: Any) -> None: + """ + Обрабатывает клик по игровому полю. + """ + if self._game_logic is None: + return + + offset_x = (event.x - OFFSET_X) % CELL_SIZE + offset_y = (event.y - OFFSET_Y) % CELL_SIZE + + grid_x = (event.x - OFFSET_X) // CELL_SIZE + (1 if offset_x >= CELL_SIZE / 2 else 0) + grid_y = (event.y - OFFSET_Y) // CELL_SIZE + (1 if offset_y >= CELL_SIZE / 2 else 0) + + grid_x = max(0, min(grid_x, self._grid_width)) + grid_y = max(0, min(grid_y, self._grid_height)) + + if 0 <= grid_x <= self._grid_width and 0 <= grid_y <= self._grid_height: + current_player_before = self._game_logic.current_player + if not self._game_logic.is_valid_move(grid_x, grid_y): + return + + game_ended = self._game_logic.place_dot(grid_x, grid_y) + + x = grid_x * CELL_SIZE + OFFSET_X + y = grid_y * CELL_SIZE + OFFSET_Y + color = self._game_logic.player_colors[current_player_before] + self._draw_dot(x, y, color) + self._draw_lines() + + if self._game_logic.last_captured > 0: + self._show_capture_message(current_player_before, self._game_logic.last_captured) + + self.update_scoreboard() + + if game_ended: + self._end_game() + return + + self._game_logic.switch_player() + + if self._mode != "PVP" and self._game_logic.current_player == 2: + self._root.after(500, self._make_ai_move) + + def _make_ai_move(self) -> None: + """ + Выполняет ход компьютера. + """ + if self._game_logic is None: + return + if self._mode == "PVAIR": + move = self._game_logic.make_ai_move_random() + else: + move = self._game_logic.make_ai_move_smart() + + if move: + grid_x, grid_y = move + game_ended = self._game_logic.place_dot(grid_x, grid_y) + + x = grid_x * CELL_SIZE + OFFSET_X + y = grid_y * CELL_SIZE + OFFSET_Y + color = self._game_logic.player_colors[2] + self._draw_dot(x, y, color) + self._draw_lines() + + if self._game_logic.last_captured > 0: + self._show_capture_message(2, self._game_logic.last_captured) + + self.update_scoreboard() + + if game_ended: + self._end_game() + return + + self._game_logic.switch_player() + + def _draw_dot(self, x: int, y: int, color: str) -> None: + """ + Рисует точку на поле. + """ + self._canvas.create_oval(x - 4, y - 4, x + 4, y + 4, fill=color, outline=color) + + def _draw_lines(self) -> None: + """ + Рисует линии между точками. + """ + self._canvas.delete("lines") + + if self._game_logic is not None: + for line in self._game_logic.lines: + x1, y1, x2, y2, color = line + self._canvas.create_line([x1, y1, x2, y2], fill=color, width=2, tags="lines") + + def update_scoreboard(self) -> None: + """ + Обновляет счет на экране. + """ + if self._game_logic is None or self._scoreboard is None: + return + + if hasattr(self._game_logic, 'scores'): + for row in self._scoreboard.get_children(): + self._scoreboard.delete(row) + + self._scoreboard.insert( + "", + "end", + values=( + f"Синий: {self._game_logic.scores.get(1, 0)}", + f"Красный: {self._game_logic.scores.get(2, 0)}", + ), + tags=("blue", "red"), + ) + + self._scoreboard.tag_configure("blue", foreground="darkblue") + self._scoreboard.tag_configure("red", foreground="darkred") + + def _show_capture_message(self, player: int, count: int) -> None: + """ + Показывает сообщение о захвате точек. + """ + color = "синий" if player == 1 else "красный" + message = f"Игрок {player} ({color}) захватил {count} точек!" + + msg_label = Label( + self._root, + text=message, + font=("Arial", 12, "bold"), + fg="white", + bg="darkblue" if player == 1 else "darkred", + ) + msg_label.place(relx=0.5, rely=0.95, anchor="center") + + self._root.after(2000, msg_label.destroy) + + def _start_new_game(self, width: int, height: int, mode: str) -> None: + """ + Запускает новую игру. + """ + self._grid_width = width + self._grid_height = height + self._mode = mode + self._game_logic = GameLogic(width, height, self) + self._game_logic.player_colors = {1: "darkblue", 2: "darkred"} + + if not hasattr(self._game_logic, 'scores'): + self._game_logic.scores = {1: 0, 2: 0} + + self._canvas.delete("all") + self._create_grid() + self.update_scoreboard() + + def _create_mode_buttons(self) -> None: + """ + Создает кнопки выбора режима. + """ + header_label = Label( + self._root, + text="Выбор режима", + font=("Arial", 12, "bold"), + bg="white", + highlightthickness=0, + ) + header_label.place(relx=1.0, rely=0.0, x=-350, y=247, anchor="ne") + + buttons = [ + ("Игрок1 / Игрок2", 280, "PVP"), + ("Игрок1 / Компьютер рандом", 340, "PVAIR"), + ("Игрок1 / Компьютер умный", 400, "PVAIS"), + ] + + for text, y_pos, mode in buttons: + button = Button( + self._root, + text=text, + command=self._create_mode_handler(mode), + fg="black", + bg="white", + font=("Arial", 10), + width=25, + height=2, + ) + button.place(x=680, y=y_pos) + + def _create_mode_handler(self, mode: str) -> Callable[[], None]: + """ + Создает обработчик для кнопок режима. + """ + return lambda: self._start_new_game(self._grid_width, self._grid_height, mode) + + def _create_size_selection(self) -> None: + """ + Создает элементы выбора размера поля. + """ + header_label = Label( + self._root, + text="Выбор размера поля", + font=("Arial", 12, "bold"), + bg="white", + highlightthickness=0, + ) + header_label.place(relx=1.0, rely=0.0, x=-70, y=250, anchor="ne") + + info_label = Label( + self._root, + text="Максимальные wxh (30x33) клеток", + font=("Arial", 10), + bg="white", + highlightthickness=0, + ) + info_label.place(relx=1.0, rely=0.0, x=-50, y=270, anchor="ne") + + custom_font = font.Font(size=14) + self._entry1 = Entry(self._root, width=15, font=custom_font) + self._entry1.place(relx=1.0, rely=0.0, x=-70, y=310, anchor="ne") + + self._entry2 = Entry(self._root, width=15, font=custom_font) + self._entry2.place(relx=1.0, rely=0.0, x=-70, y=360, anchor="ne") + + button_select = Button( + self._root, + text="Выбрать", + command=self._update_grid_size, + fg="black", + bg="white", + font=("Arial", 10), + width=23, + height=1, + ) + button_select.place(x=950, y=420) + + def _update_grid_size(self) -> None: + """ + Обновляет размер поля. + """ + if self._entry1 is None or self._entry2 is None: + return + + try: + new_width = int(self._entry1.get()) + new_height = int(self._entry2.get()) + + if new_width > 30 or new_height > 33: + messagebox.showerror("Ошибка", "Размеры не должны превышать 30x33") + return + + self._grid_width = new_width + self._grid_height = new_height + self._start_new_game(new_width, new_height, self._mode) + except ValueError: + messagebox.showerror("Ошибка", "Пожалуйста, введите корректные числа") diff --git a/dots/game_logic.py b/dots/game_logic.py new file mode 100644 index 0000000..45e6c1d --- /dev/null +++ b/dots/game_logic.py @@ -0,0 +1,247 @@ +from __future__ import annotations + +from collections import deque +import random +from typing import Any + +from constants import CELL_SIZE, OFFSET_X, OFFSET_Y + + +class GameLogic: + """ + Класс для реализации логики игры, которая управляет игровым полем, движением + игроков, захватами и состоянием игры. + """ + + PLAYER_1 = 1 + PLAYER_2 = 2 + EMPTY_CELL = 0 + + FOUR_DIRECTIONS = [(-1, 0), (1, 0), (0, -1), (0, 1)] + EIGHT_DIRECTIONS = [(-1, -1), (-1, 0), (-1, 1), (0, -1), (0, 1), (1, -1), (1, 0), (1, 1)] + + INITIAL_FILLED_CELLS = 0 + MAX_ATTEMPTS = 500 + + def __init__(self, width: int, height: int, game_ui: Any) -> None: + """Инициализация класса GameLogic.""" + self._grid_width: int = width + self._grid_height: int = height + self._game_ui: Any = game_ui + self.scores: dict[int, int] = { + self.PLAYER_1: self.INITIAL_FILLED_CELLS, + self.PLAYER_2: self.INITIAL_FILLED_CELLS, + } + self.player_colors: dict[int, str] = {self.PLAYER_1: "darkblue", self.PLAYER_2: "darkred"} + self._grid: list[list[int]] = [[self.EMPTY_CELL for _ in range(width + 1)] for _ in range(height + 1)] + self.current_player: int = self.PLAYER_1 + self.lines: list[tuple[int, int, int, int, str]] = [] + self.last_captured: int = self.INITIAL_FILLED_CELLS + self._total_cells: int = (width + 1) * (height + 1) + self._filled_cells: int = self.INITIAL_FILLED_CELLS + self._controlled_areas: dict[int, list[tuple[int, int]]] = {self.PLAYER_1: [], self.PLAYER_2: []} + + def _is_board_full(self) -> bool: + """Проверяет, заполнено ли игровое поле.""" + return self._filled_cells >= self._total_cells + + def is_valid_move(self, grid_x: int, grid_y: int) -> bool: + """Проверяет, является ли указанный ход допустимым.""" + if not (0 <= grid_x <= self._grid_width and 0 <= grid_y <= self._grid_height): + return False + return self._grid[grid_y][grid_x] == self.EMPTY_CELL + + def place_dot(self, grid_x: int, grid_y: int) -> bool: + """Помещает точку игрока на поле.""" + if not self.is_valid_move(grid_x, grid_y): + return False + + if self._grid[grid_y][grid_x] != self.EMPTY_CELL: + return False + + self._grid[grid_y][grid_x] = self.current_player + self._filled_cells += 1 + + if self._is_board_full(): + return True + + self._check_neighbors(grid_x, grid_y) + + captures = self._check_captures(grid_x, grid_y) + if captures > self.INITIAL_FILLED_CELLS: + self.scores[self.current_player] += captures + self._game_ui.update_scoreboard() + + opponent = self.PLAYER_2 if self.current_player == self.PLAYER_1 else self.PLAYER_1 + for dx, dy in self.FOUR_DIRECTIONS: + nx, ny = grid_x + dx, grid_y + dy + if 0 <= nx <= self._grid_width and 0 <= ny <= self._grid_height: + if self._grid[ny][nx] == self.current_player: + group, is_surrounded = self._get_group_with_status(nx, ny, self.current_player) + if is_surrounded: + self_captures = 0 + for gx, gy in group: + if self._grid[gy][gx] == self.current_player: + self_captures += 1 + + if self_captures > 0: + self.scores[opponent] += self_captures + self._game_ui.update_scoreboard() + + return self._is_board_full() + + def _check_neighbors(self, grid_x: int, grid_y: int) -> None: + """ + Проверяет соседей текущей клетки и добавляет линии между соседними + клетками одного игрока. + """ + color = self.player_colors[self.current_player] + for dx, dy in self.EIGHT_DIRECTIONS: + nx, ny = grid_x + dx, grid_y + dy + if 0 <= nx <= self._grid_width and 0 <= ny <= self._grid_height: + if self._grid[ny][nx] == self.current_player: + x1 = grid_x * CELL_SIZE + OFFSET_X + y1 = grid_y * CELL_SIZE + OFFSET_Y + x2 = nx * CELL_SIZE + OFFSET_X + y2 = ny * CELL_SIZE + OFFSET_Y + line = (x1, y1, x2, y2, color) + if line not in self.lines: + self.lines.append(line) + + def _check_captures(self, x: int, y: int) -> int: + """ + Проверяет захват окруженных групп противника. + Возвращает количество захваченных точек (без удаления их с поля). + """ + opponent = self.PLAYER_2 if self.current_player == self.PLAYER_1 else self.PLAYER_1 + captured = 0 + for dx, dy in self.FOUR_DIRECTIONS: + nx, ny = x + dx, y + dy + if 0 <= nx <= self._grid_width and 0 <= ny <= self._grid_height: + if self._grid[ny][nx] == opponent: + group, is_surrounded = self._get_group_with_status(nx, ny, opponent) + if is_surrounded: + captured += sum(1 for gx, gy in group if self._grid[gy][gx] == opponent) + + self.last_captured = captured + return captured + + def _get_group_with_status(self, x: int, y: int, player: int) -> tuple[list[tuple[int, int]], bool]: + """ + Получает группу точек игрока и пустых клеток, начиная с координат (x, y), + и определяет, окружена ли она точками противника или границей поля. + """ + visited = [[False for _ in range(self._grid_width + 1)] for _ in range(self._grid_height + 1)] + queue = deque([(x, y)]) + visited[y][x] = True + group = [] + is_surrounded = True + opponent = self.PLAYER_2 if player == self.PLAYER_1 else self.PLAYER_1 + + while queue: + cx, cy = queue.popleft() + group.append((cx, cy)) + + for dx, dy in self.FOUR_DIRECTIONS: + nx, ny = cx + dx, cy + dy + if 0 <= nx <= self._grid_width and 0 <= ny <= self._grid_height: + if not visited[ny][nx]: + if self._grid[ny][nx] == player or self._grid[ny][nx] == self.EMPTY_CELL: + visited[ny][nx] = True + queue.append((nx, ny)) + else: + is_surrounded = False + for cx, cy in group: + for dx, dy in self.FOUR_DIRECTIONS: + nx, ny = cx + dx, cy + dy + if 0 <= nx <= self._grid_width and 0 <= ny <= self._grid_height: + if (nx, ny) not in group and self._grid[ny][nx] != opponent: + is_surrounded = False + break + if not is_surrounded: + break + + return group, is_surrounded + + def switch_player(self) -> None: + """Меняет текущего игрока.""" + self.current_player = self.PLAYER_2 if self.current_player == self.PLAYER_1 else self.PLAYER_1 + + def make_ai_move_random(self) -> tuple[int, int] | None: + """Выполняет случайный ход для ИИ.""" + attempts = 0 + while attempts < self.MAX_ATTEMPTS: + grid_x = random.randint(0, self._grid_width) + grid_y = random.randint(0, self._grid_height) + if self.is_valid_move(grid_x, grid_y): + if self.place_dot(grid_x, grid_y): + return None + return grid_x, grid_y + attempts += 1 + return None + + def make_ai_move_smart(self) -> tuple[int, int] | None: + """ + Умный ИИ: сначала пытается окружить точки игрока, затем делает ход рядом + с ними. + """ + for y in range(self._grid_height + 1): + for x in range(self._grid_width + 1): + if self.is_valid_move(x, y): + if self._would_capture_player(x, y): + if self.place_dot(x, y): + return None + return x, y + player_dots = [] + for y in range(self._grid_height + 1): + for x in range(self._grid_width + 1): + if self._grid[y][x] == self.PLAYER_1: + player_dots.append((x, y)) + + if not player_dots: + return self.make_ai_move_random() + + candidate_moves = [] + for x, y in player_dots: + for dx, dy in self.EIGHT_DIRECTIONS: + nx, ny = x + dx, y + dy + if 0 <= nx <= self._grid_width and 0 <= ny <= self._grid_height: + if self.is_valid_move(nx, ny): + candidate_moves.append((nx, ny)) + + if not candidate_moves: + return self.make_ai_move_random() + best_move = None + best_score = -1 + + for x, y in candidate_moves: + score = 0 + for dx, dy in self.EIGHT_DIRECTIONS: + nx, ny = x + dx, y + dy + if 0 <= nx <= self._grid_width and 0 <= ny <= self._grid_height: + if self._grid[ny][nx] == self.PLAYER_1: + score += 3 + elif self._grid[ny][nx] == self.PLAYER_2: + score += 1 + + if score > best_score or (score == best_score and random.random() < 0.5): + best_score = score + best_move = (x, y) + + if best_move: + if self.place_dot(best_move[0], best_move[1]): + return None + return best_move + + return self.make_ai_move_random() + + def _would_capture_player(self, x: int, y: int) -> bool: + """Проверяет, приведет ли ход к захвату области игрока.""" + for dx, dy in self.FOUR_DIRECTIONS: + nx, ny = x + dx, y + dy + if 0 <= nx <= self._grid_width and 0 <= ny <= self._grid_height: + if self._grid[ny][nx] == self.PLAYER_1: + group, is_surrounded = self._get_group_with_status(nx, ny, self.PLAYER_1) + if is_surrounded: + return True + return False diff --git a/dots/help.py b/dots/help.py new file mode 100644 index 0000000..e452d0a --- /dev/null +++ b/dots/help.py @@ -0,0 +1,45 @@ +import argparse +import textwrap + + +def print_help() -> None: + """Выводит справочную информацию о игре.""" + help_text = """ + Игра 'Точки' - это стратегическая игра для двух игроков. + + Основные правила: + - Игроки по очереди ставят точки своего цвета на поле + - При соединении точек образуются линии + - Захваченные области подсчитываются как очки + - Побеждает игрок с наибольшим количеством очков + + Управление: + - ЛКМ - поставить точку + - Кнопки в правой панели - выбор режима игры + - Можно выбрать размер поля (до 30x33) + - Есть три режима игры: + * PVP - игрок против игрока + * PVAIR - игрок против ИИ (случайные ходы) + * PVAIS - игрок против умного ИИ + + Таблица лидеров: + - Сохраняются лучшие результаты + - Топ-3 игроков отображается в правой панели + """ + print(textwrap.dedent(help_text).strip()) + + +def main() -> None: + parser = argparse.ArgumentParser(description='Справка по игре "Точки"') + parser.add_argument('command', nargs='?', default='help', help='Команда справки') + args = parser.parse_args() + + if args.command == 'help': + print_help() + else: + print(f"Неизвестная команда: {args.command}") + print("Доступные команды: help") + + +if __name__ == "__main__": + main() diff --git a/dots/ikon.png b/dots/ikon.png new file mode 100644 index 0000000000000000000000000000000000000000..8d0148a2d7ee58cb1c559a16ca7c6a1d1f69d6ef GIT binary patch literal 74734 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 z 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�Mz%pV+%4c5Pe(=oU_jV=0_Oq0xH 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

lWnb!IBfgSGF01@J&z~u63f)`{~Es09@eC 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 literal 0 HcmV?d00001 diff --git a/dots/interface.py b/dots/interface.py new file mode 100644 index 0000000..441851b --- /dev/null +++ b/dots/interface.py @@ -0,0 +1,106 @@ +from __future__ import annotations + +from tkinter import END, VERTICAL, WORD, Label, Scrollbar, Text, Tk, ttk +from tkinter.ttk import Treeview + + +def create_leaderboard(root: Tk) -> Treeview: + """ + Создает таблицу лидеров в GUI, отображающую рейтинг игроков. + """ + _columns: tuple[str, str, str] = ("Место", "Имя", "Счет") + + _header_label: Label = Label( + root, + text="Таблица лидеров", + font=("Arial", 12, "bold"), + bg="white", + highlightthickness=0, + ) + _header_label.place(relx=1.0, rely=0.0, x=-65, y=20, anchor="ne") + + _tree: Treeview = ttk.Treeview(root, columns=_columns, show="headings") + for col in _columns: + _tree.heading(col, text=col) + _tree.column(col, width=80, anchor="center") + + _style: ttk.Style = ttk.Style() + _style.configure("Treeview", rowheight=40, font=("Arial", 10)) + _style.configure("Treeview.Heading", font=("Arial", 10, "bold"), background="lightblue") + + _num_rows: int = 3 + _row_height: int = 45 + _header_height: int = 30 + _total_height: int = _num_rows * _row_height + _header_height + + _tree.place(relx=1.0, rely=0.0, x=-20, y=50, anchor="ne", height=_total_height) + + return _tree + + +def create_scoreboard(root: Tk) -> Treeview: + """ + Создает таблицу счета в GUI, отображающую текущие очки обоих игроков. + """ + _columns: tuple[str, str] = ("Игрок1 (Синий)", "Игрок2 (Красный)") + + _header_label: Label = Label(root, text="Счет", font=("Arial", 12, "bold"), bg="white", highlightthickness=0) + _header_label.place(relx=1.0, rely=0.0, x=-400, y=110, anchor="ne") + + _tree: Treeview = ttk.Treeview(root, columns=_columns, show="headings") + for col in _columns: + _tree.heading(col, text=col) + _tree.column(col, width=120, anchor="center") + + _tree.insert("", END, values=("Синий: 0", "Красный: 0"), tags=("blue", "red")) + + _tree.tag_configure("blue", foreground="darkblue") + _tree.tag_configure("red", foreground="darkred") + + _num_rows: int = 1 + _row_height: int = 45 + _header_height: int = 30 + _total_height: int = _num_rows * _row_height + _header_height + + _tree.place(relx=1.0, rely=0.0, x=-300, y=140, anchor="ne", height=_total_height) + + return _tree + + +def create_rules(root: Tk) -> None: + """ + Создает и отображает раздел с правилами игры в GUI. + """ + _header_label: Label = Label( + root, + text="Правила игры", + font=("Arial", 12, "bold"), + bg="white", + highlightthickness=0, + ) + _header_label.place(relx=1.0, rely=0.0, x=-210, y=520, anchor="ne") + + _text_block: Text = Text(root, wrap=WORD, width=70, height=10, font=("Arial", 10)) + _text_block.insert( + END, + "Ход: игроки ходят поочерёдно, за один ход можно поставить только одну точку. Разрешено " + "рисовать точки только на пересечении линий, где нет других знаков — в свободном поле.\n" + "Цель: окружить точки соперника. Окружение — это создание на определённом участке игрового поля " + "области, замкнутой внутри непрерывной непересекающейся цепи точек одного цвета, отстоящих друг " + "от друга не более, чем на одну клетку по горизонтали, вертикали или диагонали.\n" + "Захваченные точки: если в области, окружённой точками одного цвета, есть точки другого цвета, " + "то точки соперника внутри неё выключаются из дальнейшей игры, считаясь захваченными, и идут в " + "зачёт окружившему.\n" + "Домик: если замыкается область, внутри которой нет точек соперника, она называется «домиком» и " + "не является окружением. Такая область не закрашивается, а в пустые пункты внутри неё можно ставить " + "точки.\n" + "Подсчёт очков: по окончании игры производится подсчёт окружённых точек. Победителем считается " + "игрок, захвативший хотя бы на одну точку больше, чем соперник. При равенстве по этому показателю " + "объявляется ничья.", + ) + _text_block.configure(state="disabled") + _text_block.place(relx=0.0, rely=0.0, x=670, y=550, anchor="nw") + + _scrollbar: Scrollbar = Scrollbar(root, orient=VERTICAL, command=_text_block.yview) + _scrollbar.place(relx=0.0, rely=0.0, x=1160, y=550, anchor="nw", height=160) + _text_block.config(yscrollcommand=_scrollbar.set) diff --git a/dots/leaderboard.py b/dots/leaderboard.py new file mode 100644 index 0000000..5308611 --- /dev/null +++ b/dots/leaderboard.py @@ -0,0 +1,66 @@ +from __future__ import annotations + +import json +from pathlib import Path + +from pydantic import BaseModel + + +_LEADERBOARD_SIZE = 3 +_DEFAULT_NAME = "None" +_DEFAULT_SCORE = 0 + + +class LeaderboardEntry(BaseModel): + """ + Модель записи в таблице лидеров. + """ + + place: int + name: str + score: int + + +def load_leaderboard() -> list[LeaderboardEntry]: + """ + Загружает таблицу лидеров из файла JSON. + """ + _leaderboard_file = Path("leaderboard.json") + + if not _leaderboard_file.exists(): + return [ + LeaderboardEntry(place=i + 1, name=_DEFAULT_NAME, score=_DEFAULT_SCORE) for i in range(_LEADERBOARD_SIZE) + ] + + with _leaderboard_file.open("r", encoding="utf-8") as _f: + _data = json.load(_f) + return [LeaderboardEntry(**entry) for entry in _data] + + +def save_leaderboard(leaderboard: list[LeaderboardEntry]) -> None: + """ + Сохраняет таблицу лидеров в файл JSON. + """ + _leaderboard_file = Path("leaderboard.json") + with _leaderboard_file.open("w", encoding="utf-8") as _f: + json.dump([entry.model_dump() for entry in leaderboard], _f, indent=4, ensure_ascii=False) + + +def update_leaderboard(new_name: str, new_score: int) -> bool: + """ + Обновляет таблицу лидеров новым результатом. + """ + _leaderboard = load_leaderboard() + + if new_score <= _leaderboard[-1].score and _leaderboard[-1].name != _DEFAULT_NAME: + return False + + _leaderboard.append(LeaderboardEntry(place=0, name=new_name, score=new_score)) + _leaderboard.sort(key=lambda x: x.score if x.name != _DEFAULT_NAME else -1, reverse=True) + _leaderboard = _leaderboard[:_LEADERBOARD_SIZE] + + for i, entry in enumerate(_leaderboard, 1): + entry.place = i + + save_leaderboard(_leaderboard) + return True diff --git a/dots/main.py b/dots/main.py new file mode 100644 index 0000000..d64e431 --- /dev/null +++ b/dots/main.py @@ -0,0 +1,27 @@ +import sys +from tkinter import PhotoImage, Tk + +from constants import HEIGHT, WIDTH +from dots_game import DotsGame + + +def main() -> None: + if len(sys.argv) > 1 and sys.argv[1] == 'help': + from help import print_help + + print_help() + return + + root: Tk = Tk() + root.title("Dots Game") + icon: PhotoImage = PhotoImage(file="ikon.png") + root.iconphoto(True, icon) + root.geometry(f"{WIDTH}x{HEIGHT}") + root.resizable(width=False, height=False) + game: DotsGame = DotsGame(root) + game.start() + root.mainloop() + + +if __name__ == "__main__": + main() diff --git a/dots/test_dots_game.py b/dots/test_dots_game.py new file mode 100644 index 0000000..c421eb1 --- /dev/null +++ b/dots/test_dots_game.py @@ -0,0 +1,359 @@ +from __future__ import annotations + +from tkinter import Canvas, Tk +from unittest.mock import MagicMock, patch + +from constants import CELL_SIZE, OFFSET_X, OFFSET_Y +from dots_game import DotsGame, GameResult, determine_result +from game_logic import GameLogic +import pytest + + +@pytest.fixture +def root() -> Tk: + """Фикстура: создает корневое окно Tkinter для тестирования.""" + return Tk() + + +@pytest.fixture +def game(root: Tk) -> DotsGame: + """Фикстура: создает экземпляр игры DotsGame для тестирования.""" + return DotsGame(root) + + +@pytest.fixture +def mock_game_logic() -> MagicMock: + """Фикстура: создает mock-объект для имитации GameLogic.""" + return MagicMock(spec=GameLogic) + + +def test_game_result_blue_win() -> None: + """Тест проверяет корректность значений для результата 'победа синего игрока'.""" + result = GameResult.BLUE_WIN + assert result.winner_text == "Синий игрок" + assert result.color == "darkblue" + + +def test_game_result_red_win() -> None: + """Тест проверяет корректность значений для результата 'победа красного игрока'.""" + result = GameResult.RED_WIN + assert result.winner_text == "Красный игрок" + assert result.color == "darkred" + + +def test_game_result_draw() -> None: + """Тест проверяет корректность значений для результата 'ничья'.""" + result = GameResult.DRAW + assert result.winner_text == "Ничья" + assert result.color == "black" + + +def test_determine_result_blue_win() -> None: + """Тест проверяет функцию определения результата при победе синего игрока.""" + assert determine_result(5, 3) == GameResult.BLUE_WIN + + +def test_determine_result_red_win() -> None: + """Тест проверяет функцию определения результата при победе красного игрока.""" + assert determine_result(3, 5) == GameResult.RED_WIN + + +def test_determine_result_draw() -> None: + """Тест проверяет функцию определения результата при ничьей.""" + assert determine_result(4, 4) == GameResult.DRAW + + +def test_game_initialization(game: DotsGame) -> None: + """Тест проверяет корректность инициализации игры (значения по умолчанию).""" + assert game._grid_width == 30 + assert game._grid_height == 33 + assert game._mode == "PVP" + assert game._game_logic is None + + +def test_start_game(game: DotsGame) -> None: + """Тест проверяет корректность запуска игры (создание canvas и привязка событий).""" + game.start() + assert isinstance(game._canvas, Canvas) + assert game._canvas.bind("") is not None + assert game._canvas.bind("") is not None + + +@patch('dots_game.GameLogic') +def test_start_new_game_pvp(mock_game_logic: MagicMock, game: DotsGame) -> None: + """Тест проверяет создание новой игры в режиме PVP.""" + game._start_new_game(10, 10, "PVP") + assert game._grid_width == 10 + assert game._grid_height == 10 + assert game._mode == "PVP" + mock_game_logic.assert_called_once_with(10, 10, game) + + +@patch('dots_game.GameLogic') +def test_start_new_game_pvair(mock_game_logic: MagicMock, game: DotsGame) -> None: + """Тест проверяет создание новой игры в режиме PVAIR (против ИИ).""" + game._start_new_game(15, 15, "PVAIR") + assert game._mode == "PVAIR" + mock_game_logic.assert_called_once_with(15, 15, game) + + +def test_create_grid(game: DotsGame) -> None: + """Тест проверяет корректность создания сетки на canvas.""" + game._grid_width = 5 + game._grid_height = 5 + game._create_grid() + canvas_items = game._canvas.find_all() + assert len(canvas_items) == (5 + 1) * 2 + + +def test_draw_dot(game: DotsGame) -> None: + """Тест проверяет корректность отрисовки точки на canvas.""" + game._draw_dot(50, 50, "red") + canvas_items = game._canvas.find_all() + assert len(canvas_items) == 1 + assert game._canvas.itemcget(canvas_items[0], "fill") == "red" + + +def test_draw_lines(game: DotsGame, mock_game_logic: MagicMock) -> None: + """Тест проверяет корректность отрисовки линий на canvas.""" + game._game_logic = mock_game_logic + mock_game_logic.lines = [(10, 10, 20, 20, "blue")] + game._draw_lines() + canvas_items = game._canvas.find_withtag("lines") + assert len(canvas_items) == 1 + + +def test_update_scoreboard(game: DotsGame, mock_game_logic: MagicMock) -> None: + """Тест проверяет корректность обновления отображения счета.""" + game._game_logic = mock_game_logic + mock_game_logic.scores = {1: 5, 2: 3} + game._scoreboard = MagicMock() + + game.update_scoreboard() + assert game._scoreboard is not None + + +def test_show_capture_message(game: DotsGame) -> None: + """Тест проверяет, что сообщение о захвате отображается на заданное время.""" + with patch.object(game._root, 'after') as mock_after: + game._show_capture_message(1, 3) + args, kwargs = mock_after.call_args + assert args[0] == 2000 + assert callable(args[1]) + + +@patch('dots_game.GameLogic') +def test_on_canvas_click_with_game_logic(mock_game_logic: MagicMock, game: DotsGame) -> None: + """Test canvas click handling with game logic.""" + # Create a proper mock for game logic + mock_logic = MagicMock() + mock_logic.place_dot.return_value = False + mock_logic.current_player = 1 + mock_logic.player_colors = {1: "blue"} + mock_logic.last_captured = 0 + mock_logic.scores = {1: 0, 2: 0} + mock_logic.lines = [] + + game._game_logic = mock_logic + game._canvas = MagicMock() + game._canvas.find_all.return_value = [1] + + event = MagicMock() + event.x = OFFSET_X + CELL_SIZE + event.y = OFFSET_Y + CELL_SIZE + + game._on_canvas_click(event) + + game._canvas.create_oval.assert_called_once() + + +@patch('dots_game.GameLogic') +def test_on_canvas_click_game_ended(mock_game_logic: MagicMock, game: DotsGame) -> None: + """Test canvas click handling when game ends.""" + # Create a proper mock for game logic + mock_logic = MagicMock() + mock_logic.place_dot.return_value = True + mock_logic.current_player = 1 + mock_logic.player_colors = {1: "blue"} + mock_logic.last_captured = 1 + mock_logic.scores = {1: 0, 2: 0} + mock_logic.lines = [] + mock_logic.is_game_over = True + + game._game_logic = mock_logic + game._canvas = MagicMock() + + with patch.object(game, '_end_game') as mock_end_game: + event = MagicMock() + event.x = OFFSET_X + CELL_SIZE + event.y = OFFSET_Y + CELL_SIZE + game._on_canvas_click(event) + mock_end_game.assert_called_once() + + +def test_on_canvas_click_no_game_logic(game: DotsGame) -> None: + """Тест проверяет обработку клика по canvas при отсутствии игровой логики.""" + event = MagicMock() + event.x = 100 + event.y = 100 + game._on_canvas_click(event) + assert game._game_logic is None + + +def test_make_ai_move_random(game: DotsGame, mock_game_logic: MagicMock) -> None: + """ + Тест проверяет, что в режиме "PVAIR" вызывается метод make_ai_move_random + объекта GameLogic. + """ + game._mode = "PVAIR" + game._game_logic = mock_game_logic + mock_game_logic.make_ai_move_random.return_value = (5, 5) + mock_game_logic.player_colors = {1: "blue", 2: "red"} + mock_game_logic.lines = [] + mock_game_logic.last_captured = 0 + mock_game_logic.scores = {1: 0, 2: 0} + game._make_ai_move() + mock_game_logic.make_ai_move_random.assert_called_once() + + +def test_make_ai_move_smart(game: DotsGame, mock_game_logic: MagicMock) -> None: + """ + Тест проверяет, что в режиме "PVAIS" вызывается метод make_ai_move_smart + объекта GameLogic. + """ + game._mode = "PVAIS" + game._game_logic = mock_game_logic + mock_game_logic.make_ai_move_smart.return_value = (5, 5) + game._game_logic.player_colors = {1: "blue", 2: "red"} + game._game_logic.lines = [] + game._game_logic.last_captured = 0 + mock_game_logic.scores = {1: 0, 2: 0} + game._make_ai_move() + mock_game_logic.make_ai_move_smart.assert_called_once() + + +def test_end_game_no_logic(game: DotsGame) -> None: + """Тест проверяет, что при отсутствии игровой логики _game_logic становится None.""" + game._end_game() + assert game._game_logic is None + + +@patch('dots_game.messagebox') +def test_end_game_with_logic(mock_messagebox: MagicMock, game: DotsGame, mock_game_logic: MagicMock) -> None: + """ + Тест проверяет поведение _end_game при наличии игровой логики и завершенной игре. + Ожидается вызов _show_winner_dialog и отсутствие вызова messagebox. + """ + game._game_logic = mock_game_logic + mock_game_logic.scores = {1: 5, 2: 3} + mock_game_logic.is_game_over = True + with patch.object(game, '_show_winner_dialog') as mock_show_winner: + game._end_game() + mock_show_winner.assert_called_once() + mock_messagebox.assert_not_called() + + +def test_create_mode_buttons(game: DotsGame) -> None: + """Тест проверяет создание кнопок выбора режима игры.""" + game._create_mode_buttons() + assert len(game._root.winfo_children()) > 3 + + +def test_create_size_selection(game: DotsGame) -> None: + """Тест проверяет создание элементов выбора размера игрового поля.""" + game._create_size_selection() + assert game._entry1 is not None + assert game._entry2 is not None + + +@patch('dots_game.messagebox') +def test_update_grid_size_invalid(mock_messagebox: MagicMock, game: DotsGame) -> None: + """ + Тест проверяет обработку некорректного ввода размера игрового поля. + Ожидается отображение сообщения об ошибке. + """ + game._entry1 = MagicMock() + game._entry1.get.return_value = "abc" + game._entry2 = MagicMock() + game._entry2.get.return_value = "10" + + game._update_grid_size() + mock_messagebox.showerror.assert_called_once() + + +@patch('dots_game.messagebox') +def test_update_grid_size_too_large(mock_messagebox: MagicMock, game: DotsGame) -> None: + """ + Тест проверяет обработку ввода слишком большого размера игрового поля. + Ожидается отображение сообщения об ошибке. + """ + game._entry1 = MagicMock() + game._entry1.get.return_value = "40" + game._entry2 = MagicMock() + game._entry2.get.return_value = "40" + + game._update_grid_size() + mock_messagebox.showerror.assert_called_once() + + +def test_show_winner_dialog(game: DotsGame) -> None: + """Тест проверяет создание и вызов диалогового окна с сообщением о победителе.""" + with patch('dots_game.Toplevel') as mock_toplevel: + game._show_winner_dialog(GameResult.BLUE_WIN, 5, "Test message") + mock_toplevel.assert_called_once() + + +def test_create_end_game_button(game: DotsGame) -> None: + """Тест проверяет создание кнопки завершения игры с корректным текстом.""" + game._create_end_game_button() + assert game._end_button is not None + assert "Завершить игру" in game._end_button.cget("text") + + +def test_bind_events(game: DotsGame) -> None: + """Тест проверяет привязку событий к canvas (изменение размера и клик).""" + game._bind_events() + assert game._canvas.bind("") is not None + assert game._canvas.bind("") is not None + + +def test_init_interface_elements(game: DotsGame) -> None: + """Тест проверяет инициализацию основных элементов интерфейса (таблица лидеров, счет).""" + game._init_interface_elements() + assert game._leaderboard_tree is not None + assert game._scoreboard is not None + + +@patch('dots_game.load_leaderboard') +def test_init_leaderboard_data(mock_load: MagicMock, game: DotsGame) -> None: + """Тест проверяет загрузку данных для таблицы лидеров при инициализации.""" + mock_load.return_value = [MagicMock(place=1, name="Test", score=10)] + game._init_leaderboard_data() + assert game._leaderboard is not None + mock_load.assert_called_once() + + +def test_update_leaderboard_display(game: DotsGame) -> None: + """Тест проверяет обновление отображения таблицы лидеров.""" + game._leaderboard_tree = MagicMock() + game._leaderboard = [MagicMock(place=1, name="Test", score=10)] + game._update_leaderboard_display() + game._leaderboard_tree.insert.assert_called_once() + + +def test_update_grid_size_valid(game: DotsGame) -> None: + """ + Тест проверяет обновление размеров игрового поля при корректном вводе + и запуск новой игры с заданными размерами. + """ + game._entry1 = MagicMock() + game._entry1.get.return_value = "10" + game._entry2 = MagicMock() + game._entry2.get.return_value = "12" + + with patch.object(game, '_start_new_game') as mock_start_new: + game._update_grid_size() + + mock_start_new.assert_called_once_with(10, 12, game._mode) + assert game._grid_width == 10 + assert game._grid_height == 12 diff --git a/dots/test_game_logic.py b/dots/test_game_logic.py new file mode 100644 index 0000000..50d782a --- /dev/null +++ b/dots/test_game_logic.py @@ -0,0 +1,227 @@ +from unittest.mock import MagicMock + +from dots_game import GameLogic +import pytest + + +@pytest.fixture +def game_logic() -> GameLogic: + """Фикстура для создания экземпляра GameLogic с моком UI""" + ui_mock = MagicMock() + return GameLogic(width=5, height=5, game_ui=ui_mock) + + +def test_initialization(game_logic: GameLogic) -> None: + """Тест инициализации GameLogic""" + assert game_logic._grid_width == 5 + assert game_logic._grid_height == 5 + assert game_logic.current_player == GameLogic.PLAYER_1 + assert game_logic.scores == {1: 0, 2: 0} + assert len(game_logic._grid) == 6 + assert len(game_logic._grid[0]) == 6 + + +def test_is_board_full(game_logic: GameLogic) -> None: + """Тест проверки заполненности поля""" + assert not game_logic._is_board_full() + + for y in range(6): + for x in range(6): + game_logic._grid[y][x] = GameLogic.PLAYER_1 + game_logic._filled_cells = 36 + + assert game_logic._is_board_full() + + +def test_is_valid_move(game_logic: GameLogic) -> None: + """Тест проверки валидности хода""" + assert game_logic.is_valid_move(0, 0) + assert game_logic.is_valid_move(5, 5) + + assert not game_logic.is_valid_move(-1, 0) + assert not game_logic.is_valid_move(6, 5) + + game_logic._grid[2][3] = GameLogic.PLAYER_1 + assert not game_logic.is_valid_move(3, 2) + + +def test_place_dot_invalid_move(game_logic: GameLogic) -> None: + """Тест размещения точки с невалидным ходом""" + result = game_logic.place_dot(-1, 0) + assert not result + assert game_logic._filled_cells == 0 + + +def test_place_dot_valid_move(game_logic: GameLogic) -> None: + """Тест размещения точки с валидным ходом""" + result = game_logic.place_dot(2, 3) + assert result is False + assert game_logic._grid[3][2] == GameLogic.PLAYER_1 + assert game_logic._filled_cells == 1 + + +def test_place_dot_board_full(game_logic: GameLogic) -> None: + """Тест заполнения всего поля""" + for y in range(6): + for x in range(6): + if x != 3 or y != 3: + game_logic._grid[y][x] = GameLogic.PLAYER_1 + game_logic._filled_cells += 1 + + result = game_logic.place_dot(3, 3) + assert result is True + assert game_logic._is_board_full() + + +def test_check_neighbors(game_logic: GameLogic) -> None: + """Тест проверки соседей и создания линий""" + game_logic._grid[2][2] = GameLogic.PLAYER_1 + game_logic._check_neighbors(3, 3) + + assert len(game_logic.lines) == 1 + line = game_logic.lines[0] + assert line[4] == game_logic.player_colors[GameLogic.PLAYER_1] + + +def test_check_captures_no_capture(game_logic: GameLogic) -> None: + """Тест проверки захвата без захвата""" + game_logic._grid[2][3] = GameLogic.PLAYER_2 + captures = game_logic._check_captures(3, 3) + assert captures == 0 + + +def test_get_group_with_status(game_logic: GameLogic) -> None: + """Тест получения группы и статуса окружения""" + game_logic._grid[1][1] = GameLogic.PLAYER_1 + game_logic._grid[1][2] = GameLogic.PLAYER_1 + + group, surrounded = game_logic._get_group_with_status(1, 1, GameLogic.PLAYER_1) + assert len(group) >= 2 + assert not surrounded + + +def test_switch_player(game_logic: GameLogic) -> None: + """Тест смены игрока""" + assert game_logic.current_player == GameLogic.PLAYER_1 + game_logic.switch_player() + assert game_logic.current_player == GameLogic.PLAYER_2 + game_logic.switch_player() + assert game_logic.current_player == GameLogic.PLAYER_1 + + +def test_make_ai_move_random_failure(game_logic: GameLogic) -> None: + """Тест случайного хода ИИ (неудачный случай)""" + for y in range(6): + for x in range(6): + game_logic._grid[y][x] = GameLogic.PLAYER_1 + + move = game_logic.make_ai_move_random() + assert move is None + + +def test_make_ai_move_smart_capture(game_logic: GameLogic) -> None: + """Тест умного хода ИИ с захватом""" + game_logic._grid[1][1] = GameLogic.PLAYER_1 + game_logic._grid[1][2] = GameLogic.PLAYER_1 + game_logic._grid[2][1] = GameLogic.PLAYER_1 + + move = game_logic.make_ai_move_smart() + assert move == (2, 2) + + +def test_make_ai_move_smart_no_capture(game_logic: GameLogic) -> None: + """Тест умного хода ИИ без возможности захвата""" + move = game_logic.make_ai_move_smart() + assert move is not None + + +def test_would_capture_player_false(game_logic: GameLogic) -> None: + """Тест проверки потенциального захвата (False)""" + assert not game_logic._would_capture_player(0, 0) + + +def test_place_dot_with_self_capture(game_logic: GameLogic) -> None: + """Тест размещения точки с самозахватом""" + game_logic._grid[1][1] = GameLogic.PLAYER_1 + game_logic._grid[1][2] = GameLogic.PLAYER_1 + game_logic._grid[2][1] = GameLogic.PLAYER_2 + game_logic._grid[2][3] = GameLogic.PLAYER_2 + game_logic._grid[3][2] = GameLogic.PLAYER_2 + + game_logic.place_dot(2, 2) + + assert game_logic.scores[GameLogic.PLAYER_2] == 0 + + +def test_edge_case_max_board_size() -> None: + """Тест большого размера поля""" + ui_mock = MagicMock() + game = GameLogic(width=30, height=30, game_ui=ui_mock) + + assert game.is_valid_move(30, 30) + assert not game.is_valid_move(31, 30) + + +def test_controlled_areas_after_capture(game_logic: GameLogic) -> None: + """Тест обновления контролируемых областей после захвата""" + game_logic._grid[1][1] = GameLogic.PLAYER_2 + game_logic._grid[1][2] = GameLogic.PLAYER_2 + game_logic._grid[2][1] = GameLogic.PLAYER_1 + game_logic._grid[2][3] = GameLogic.PLAYER_1 + game_logic._grid[3][2] = GameLogic.PLAYER_1 + + game_logic.place_dot(2, 2) + + assert game_logic.scores[GameLogic.PLAYER_1] >= 0 + + +def test_ai_smart_move_prefers_capture(game_logic: GameLogic) -> None: + """Тест, что умный ИИ предпочитает ходы с захватом""" + game_logic.current_player = GameLogic.PLAYER_2 + game_logic._grid[1][1] = GameLogic.PLAYER_1 + game_logic._grid[1][2] = GameLogic.PLAYER_1 + game_logic._grid[2][1] = GameLogic.PLAYER_2 + game_logic._grid[2][3] = GameLogic.PLAYER_2 + game_logic._grid[3][2] = GameLogic.PLAYER_2 + + game_logic._grid[4][4] = GameLogic.PLAYER_2 + game_logic._grid[4][5] = GameLogic.PLAYER_2 + + move = game_logic.make_ai_move_smart() + assert move == (2, 2) + + +def test_would_capture_player_true(game_logic: GameLogic) -> None: + """Тест проверки потенциального захвата (True) - окончательная версия""" + game_logic.current_player = GameLogic.PLAYER_2 + game_logic._grid[1][1] = GameLogic.PLAYER_1 + + game_logic._grid[0][1] = GameLogic.PLAYER_2 + game_logic._grid[2][1] = GameLogic.PLAYER_2 + game_logic._grid[1][0] = GameLogic.PLAYER_2 + game_logic._grid[1][2] = GameLogic.PLAYER_2 + assert game_logic._would_capture_player(1, 2) + assert game_logic._would_capture_player(0, 1) + + +def test_edge_cases_for_group_detection(game_logic: GameLogic) -> None: + """Тест крайних случаев для обнаружения групп""" + # Проверяем группы на краях поля + game_logic._grid[0][0] = GameLogic.PLAYER_1 + game_logic._grid[0][1] = GameLogic.PLAYER_1 + game_logic._grid[1][0] = GameLogic.PLAYER_1 + + group, surrounded = game_logic._get_group_with_status(0, 0, GameLogic.PLAYER_1) + assert (0, 0) in group + assert (0, 1) in group + assert (1, 0) in group + assert not surrounded + game_logic._grid[4][4] = GameLogic.PLAYER_2 + game_logic._grid[4][5] = GameLogic.PLAYER_2 + game_logic._grid[5][4] = GameLogic.PLAYER_2 + + group, surrounded = game_logic._get_group_with_status(4, 4, GameLogic.PLAYER_2) + assert (4, 4) in group + assert (4, 5) in group + assert (5, 4) in group + assert not surrounded diff --git a/dots/test_leaderboard.py b/dots/test_leaderboard.py new file mode 100644 index 0000000..4d36c73 --- /dev/null +++ b/dots/test_leaderboard.py @@ -0,0 +1,174 @@ +from __future__ import annotations + +import json +from pathlib import Path +from unittest.mock import MagicMock, patch + +from leaderboard import ( + _DEFAULT_NAME, + _DEFAULT_SCORE, + _LEADERBOARD_SIZE, + LeaderboardEntry, + load_leaderboard, + save_leaderboard, + update_leaderboard, +) +import pytest + + +@pytest.fixture +def temp_leaderboard_file(tmp_path: Path) -> Path: + """Фикстура: создает временный файл для хранения таблицы лидеров.""" + return tmp_path / "leaderboard.json" + + +@pytest.fixture +def sample_leaderboard() -> list[LeaderboardEntry]: + """Фикстура: возвращает тестовые данные таблицы лидеров с 3 записями.""" + return [ + LeaderboardEntry(place=1, name="Alice", score=100), + LeaderboardEntry(place=2, name="Bob", score=80), + LeaderboardEntry(place=3, name="Charlie", score=50), + ] + + +@patch("leaderboard.Path") +def test_load_leaderboard_no_file(mock_path: MagicMock, temp_leaderboard_file: Path) -> None: + """ + Загрузка таблицы лидеров при отсутствии файла. + Проверяет, что создается таблица с записями по умолчанию. + """ + mock_path.return_value = temp_leaderboard_file + + result = load_leaderboard() + + assert len(result) == _LEADERBOARD_SIZE + for i, entry in enumerate(result, 1): + assert entry.place == i + assert entry.name == _DEFAULT_NAME + assert entry.score == _DEFAULT_SCORE + + +@patch("leaderboard.Path") +def test_load_leaderboard_existing_file( + mock_path: MagicMock, temp_leaderboard_file: Path, sample_leaderboard: list[LeaderboardEntry] +) -> None: + """ + Загрузка таблицы лидеров из существующего файла. + Проверяет корректность чтения и формат данных. + """ + mock_path.return_value = temp_leaderboard_file + + with open(temp_leaderboard_file, "w", encoding="utf-8") as f: + json.dump([entry.model_dump() for entry in sample_leaderboard], f) + + result = load_leaderboard() + + assert len(result) == _LEADERBOARD_SIZE + assert result[0].name == "Alice" + assert result[1].score == 80 + assert result[2].place == 3 + + +@patch("leaderboard.Path") +def test_save_leaderboard( + mock_path: MagicMock, temp_leaderboard_file: Path, sample_leaderboard: list[LeaderboardEntry] +) -> None: + """ + Сохранение таблицы лидеров в файл. + Проверяет создание файла и корректность сохраненных данных. + """ + mock_path.return_value = temp_leaderboard_file + + save_leaderboard(sample_leaderboard) + + assert temp_leaderboard_file.exists() + with open(temp_leaderboard_file, "r", encoding="utf-8") as f: + data = json.load(f) + assert len(data) == _LEADERBOARD_SIZE + assert data[0]["name"] == "Alice" + assert data[1]["score"] == 80 + + +@patch("leaderboard.Path") +def test_update_leaderboard_new_high_score( + mock_path: MagicMock, temp_leaderboard_file: Path, sample_leaderboard: list[LeaderboardEntry] +) -> None: + """ + Обновление таблицы при попадании нового результата в топ. + Проверяет корректность обновления и сортировки записей. + """ + mock_path.return_value = temp_leaderboard_file + + with open(temp_leaderboard_file, "w", encoding="utf-8") as f: + json.dump([entry.model_dump() for entry in sample_leaderboard], f) + + updated = update_leaderboard("Dave", 90) + + assert updated is True + leaderboard = load_leaderboard() + assert leaderboard[0].name == "Alice" + assert leaderboard[1].name == "Dave" + assert leaderboard[1].score == 90 + assert leaderboard[2].name == "Bob" + + +@patch("leaderboard.Path") +def test_update_leaderboard_low_score( + mock_path: MagicMock, temp_leaderboard_file: Path, sample_leaderboard: list[LeaderboardEntry] +) -> None: + """ + Попытка обновления с результатом ниже минимального в таблице. + Проверяет, что таблица не изменяется при низком результате. + """ + mock_path.return_value = temp_leaderboard_file + + with open(temp_leaderboard_file, "w", encoding="utf-8") as f: + json.dump([entry.model_dump() for entry in sample_leaderboard], f) + + updated = update_leaderboard("Eve", 40) + + assert updated is False + leaderboard = load_leaderboard() + assert leaderboard[0].name == "Alice" + assert leaderboard[1].name == "Bob" + assert leaderboard[2].name == "Charlie" + + +@patch("leaderboard.Path") +def test_update_leaderboard_empty_slot(mock_path: MagicMock, temp_leaderboard_file: Path) -> None: + """ + Обновление пустой таблицы (все записи по умолчанию). + Проверяет добавление первой записи в таблицу. + """ + mock_path.return_value = temp_leaderboard_file + + initial_leaderboard = [ + LeaderboardEntry(place=i + 1, name=_DEFAULT_NAME, score=_DEFAULT_SCORE) for i in range(_LEADERBOARD_SIZE) + ] + save_leaderboard(initial_leaderboard) + + updated = update_leaderboard("FirstPlayer", 10) + + assert updated is True + leaderboard = load_leaderboard() + assert leaderboard[0].name == "FirstPlayer" + assert leaderboard[0].score == 10 + assert leaderboard[1].name == _DEFAULT_NAME + assert leaderboard[2].name == _DEFAULT_NAME + + +@patch("leaderboard.Path") +def test_leaderboard_size_maintained(mock_path: MagicMock, temp_leaderboard_file: Path) -> None: + """ + Проверка сохранения размера таблицы при множественных обновлениях. + Проверяет, что таблица никогда не превышает заданный размер. + """ + mock_path.return_value = temp_leaderboard_file + + for i in range(_LEADERBOARD_SIZE * 2): + update_leaderboard(f"Player{i}", i * 10) + leaderboard = load_leaderboard() + assert len(leaderboard) == _LEADERBOARD_SIZE + assert leaderboard[0].score == (_LEADERBOARD_SIZE * 2 - 1) * 10 + assert leaderboard[-1].score >= (_LEADERBOARD_SIZE - 1) * 10 diff --git a/mypy.ini b/mypy.ini new file mode 100644 index 0000000..90ba7b9 --- /dev/null +++ b/mypy.ini @@ -0,0 +1,27 @@ +[mypy] + +python_version = 3.12 + +enable_error_code = truthy-bool, ignore-without-code + +check_untyped_defs = True + +strict_optional = True +disallow_any_generics = True +disallow_incomplete_defs = True +disallow_untyped_defs = True +no_implicit_optional = True + +warn_unused_ignores = True +warn_return_any = True +warn_no_return = True +warn_redundant_casts = True +warn_unreachable = True + +show_error_codes = True +show_column_numbers = True +pretty = True + +ignore_missing_imports = True +follow_imports = normal +follow_imports_for_stubs = True diff --git a/poetry.lock b/poetry.lock new file mode 100644 index 0000000..117e271 --- /dev/null +++ b/poetry.lock @@ -0,0 +1,598 @@ +# This file is automatically @generated by Poetry 2.1.2 and should not be changed by hand. + +[[package]] +name = "annotated-types" +version = "0.7.0" +description = "Reusable constraint types to use with typing.Annotated" +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53"}, + {file = "annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89"}, +] + +[[package]] +name = "black" +version = "25.1.0" +description = "The uncompromising code formatter." +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "black-25.1.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:759e7ec1e050a15f89b770cefbf91ebee8917aac5c20483bc2d80a6c3a04df32"}, + {file = "black-25.1.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:0e519ecf93120f34243e6b0054db49c00a35f84f195d5bce7e9f5cfc578fc2da"}, + {file = "black-25.1.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:055e59b198df7ac0b7efca5ad7ff2516bca343276c466be72eb04a3bcc1f82d7"}, + {file = "black-25.1.0-cp310-cp310-win_amd64.whl", hash = "sha256:db8ea9917d6f8fc62abd90d944920d95e73c83a5ee3383493e35d271aca872e9"}, + {file = "black-25.1.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a39337598244de4bae26475f77dda852ea00a93bd4c728e09eacd827ec929df0"}, + {file = "black-25.1.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:96c1c7cd856bba8e20094e36e0f948718dc688dba4a9d78c3adde52b9e6c2299"}, + {file = "black-25.1.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bce2e264d59c91e52d8000d507eb20a9aca4a778731a08cfff7e5ac4a4bb7096"}, + {file = "black-25.1.0-cp311-cp311-win_amd64.whl", hash = "sha256:172b1dbff09f86ce6f4eb8edf9dede08b1fce58ba194c87d7a4f1a5aa2f5b3c2"}, + {file = "black-25.1.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4b60580e829091e6f9238c848ea6750efed72140b91b048770b64e74fe04908b"}, + {file = "black-25.1.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1e2978f6df243b155ef5fa7e558a43037c3079093ed5d10fd84c43900f2d8ecc"}, + {file = "black-25.1.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3b48735872ec535027d979e8dcb20bf4f70b5ac75a8ea99f127c106a7d7aba9f"}, + {file = "black-25.1.0-cp312-cp312-win_amd64.whl", hash = "sha256:ea0213189960bda9cf99be5b8c8ce66bb054af5e9e861249cd23471bd7b0b3ba"}, + {file = "black-25.1.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8f0b18a02996a836cc9c9c78e5babec10930862827b1b724ddfe98ccf2f2fe4f"}, + {file = "black-25.1.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:afebb7098bfbc70037a053b91ae8437c3857482d3a690fefc03e9ff7aa9a5fd3"}, + {file = "black-25.1.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:030b9759066a4ee5e5aca28c3c77f9c64789cdd4de8ac1df642c40b708be6171"}, + {file = "black-25.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:a22f402b410566e2d1c950708c77ebf5ebd5d0d88a6a2e87c86d9fb48afa0d18"}, + {file = "black-25.1.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:a1ee0a0c330f7b5130ce0caed9936a904793576ef4d2b98c40835d6a65afa6a0"}, + {file = "black-25.1.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:f3df5f1bf91d36002b0a75389ca8663510cf0531cca8aa5c1ef695b46d98655f"}, + {file = "black-25.1.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d9e6827d563a2c820772b32ce8a42828dc6790f095f441beef18f96aa6f8294e"}, + {file = "black-25.1.0-cp39-cp39-win_amd64.whl", hash = "sha256:bacabb307dca5ebaf9c118d2d2f6903da0d62c9faa82bd21a33eecc319559355"}, + {file = "black-25.1.0-py3-none-any.whl", hash = "sha256:95e8176dae143ba9097f351d174fdaf0ccd29efb414b362ae3fd72bf0f710717"}, + {file = "black-25.1.0.tar.gz", hash = "sha256:33496d5cd1222ad73391352b4ae8da15253c5de89b93a80b3e2c8d9a19ec2666"}, +] + +[package.dependencies] +click = ">=8.0.0" +mypy-extensions = ">=0.4.3" +packaging = ">=22.0" +pathspec = ">=0.9.0" +platformdirs = ">=2" + +[package.extras] +colorama = ["colorama (>=0.4.3)"] +d = ["aiohttp (>=3.10)"] +jupyter = ["ipython (>=7.8.0)", "tokenize-rt (>=3.2.0)"] +uvloop = ["uvloop (>=0.15.2)"] + +[[package]] +name = "cfgv" +version = "3.4.0" +description = "Validate configuration and produce human readable error messages." +optional = false +python-versions = ">=3.8" +groups = ["main", "dev"] +files = [ + {file = "cfgv-3.4.0-py2.py3-none-any.whl", hash = "sha256:b7265b1f29fd3316bfcd2b330d63d024f2bfd8bcb8b0272f8e19a504856c48f9"}, + {file = "cfgv-3.4.0.tar.gz", hash = "sha256:e52591d4c5f5dead8e0f673fb16db7949d2cfb3f7da4582893288f0ded8fe560"}, +] + +[[package]] +name = "click" +version = "8.1.8" +description = "Composable command line interface toolkit" +optional = false +python-versions = ">=3.7" +groups = ["main"] +files = [ + {file = "click-8.1.8-py3-none-any.whl", hash = "sha256:63c132bbbed01578a06712a2d1f497bb62d9c1c0d329b7903a866228027263b2"}, + {file = "click-8.1.8.tar.gz", hash = "sha256:ed53c9d8990d83c2a27deae68e4ee337473f6330c040a31d4225c9574d16096a"}, +] + +[package.dependencies] +colorama = {version = "*", markers = "platform_system == \"Windows\""} + +[[package]] +name = "colorama" +version = "0.4.6" +description = "Cross-platform colored terminal text." +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" +groups = ["main"] +markers = "platform_system == \"Windows\"" +files = [ + {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, + {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, +] + +[[package]] +name = "distlib" +version = "0.3.9" +description = "Distribution utilities" +optional = false +python-versions = "*" +groups = ["main", "dev"] +files = [ + {file = "distlib-0.3.9-py2.py3-none-any.whl", hash = "sha256:47f8c22fd27c27e25a65601af709b38e4f0a45ea4fc2e710f65755fa8caaaf87"}, + {file = "distlib-0.3.9.tar.gz", hash = "sha256:a60f20dea646b8a33f3e7772f74dc0b2d0772d2837ee1342a00645c81edf9403"}, +] + +[[package]] +name = "filelock" +version = "3.18.0" +description = "A platform independent file lock." +optional = false +python-versions = ">=3.9" +groups = ["main", "dev"] +files = [ + {file = "filelock-3.18.0-py3-none-any.whl", hash = "sha256:c401f4f8377c4464e6db25fff06205fd89bdd83b65eb0488ed1b160f780e21de"}, + {file = "filelock-3.18.0.tar.gz", hash = "sha256:adbc88eabb99d2fec8c9c1b229b171f18afa655400173ddc653d5d01501fb9f2"}, +] + +[package.extras] +docs = ["furo (>=2024.8.6)", "sphinx (>=8.1.3)", "sphinx-autodoc-typehints (>=3)"] +testing = ["covdefaults (>=2.3)", "coverage (>=7.6.10)", "diff-cover (>=9.2.1)", "pytest (>=8.3.4)", "pytest-asyncio (>=0.25.2)", "pytest-cov (>=6)", "pytest-mock (>=3.14)", "pytest-timeout (>=2.3.1)", "virtualenv (>=20.28.1)"] +typing = ["typing-extensions (>=4.12.2) ; python_version < \"3.11\""] + +[[package]] +name = "flake8" +version = "7.2.0" +description = "the modular source code checker: pep8 pyflakes and co" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "flake8-7.2.0-py2.py3-none-any.whl", hash = "sha256:93b92ba5bdb60754a6da14fa3b93a9361fd00a59632ada61fd7b130436c40343"}, + {file = "flake8-7.2.0.tar.gz", hash = "sha256:fa558ae3f6f7dbf2b4f22663e5343b6b6023620461f8d4ff2019ef4b5ee70426"}, +] + +[package.dependencies] +mccabe = ">=0.7.0,<0.8.0" +pycodestyle = ">=2.13.0,<2.14.0" +pyflakes = ">=3.3.0,<3.4.0" + +[[package]] +name = "identify" +version = "2.6.10" +description = "File identification library for Python" +optional = false +python-versions = ">=3.9" +groups = ["main", "dev"] +files = [ + {file = "identify-2.6.10-py2.py3-none-any.whl", hash = "sha256:5f34248f54136beed1a7ba6a6b5c4b6cf21ff495aac7c359e1ef831ae3b8ab25"}, + {file = "identify-2.6.10.tar.gz", hash = "sha256:45e92fd704f3da71cc3880036633f48b4b7265fd4de2b57627cb157216eb7eb8"}, +] + +[package.extras] +license = ["ukkonen"] + +[[package]] +name = "isort" +version = "6.0.1" +description = "A Python utility / library to sort Python imports." +optional = false +python-versions = ">=3.9.0" +groups = ["main"] +files = [ + {file = "isort-6.0.1-py3-none-any.whl", hash = "sha256:2dc5d7f65c9678d94c88dfc29161a320eec67328bc97aad576874cb4be1e9615"}, + {file = "isort-6.0.1.tar.gz", hash = "sha256:1cb5df28dfbc742e490c5e41bad6da41b805b0a8be7bc93cd0fb2a8a890ac450"}, +] + +[package.extras] +colors = ["colorama"] +plugins = ["setuptools"] + +[[package]] +name = "mccabe" +version = "0.7.0" +description = "McCabe checker, plugin for flake8" +optional = false +python-versions = ">=3.6" +groups = ["main"] +files = [ + {file = "mccabe-0.7.0-py2.py3-none-any.whl", hash = "sha256:6c2d30ab6be0e4a46919781807b4f0d834ebdd6c6e3dca0bda5a15f863427b6e"}, + {file = "mccabe-0.7.0.tar.gz", hash = "sha256:348e0240c33b60bbdf4e523192ef919f28cb2c3d7d5c7794f74009290f236325"}, +] + +[[package]] +name = "mypy" +version = "1.15.0" +description = "Optional static typing for Python" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "mypy-1.15.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:979e4e1a006511dacf628e36fadfecbcc0160a8af6ca7dad2f5025529e082c13"}, + {file = "mypy-1.15.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:c4bb0e1bd29f7d34efcccd71cf733580191e9a264a2202b0239da95984c5b559"}, + {file = "mypy-1.15.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:be68172e9fd9ad8fb876c6389f16d1c1b5f100ffa779f77b1fb2176fcc9ab95b"}, + {file = "mypy-1.15.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c7be1e46525adfa0d97681432ee9fcd61a3964c2446795714699a998d193f1a3"}, + {file = "mypy-1.15.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:2e2c2e6d3593f6451b18588848e66260ff62ccca522dd231cd4dd59b0160668b"}, + {file = "mypy-1.15.0-cp310-cp310-win_amd64.whl", hash = "sha256:6983aae8b2f653e098edb77f893f7b6aca69f6cffb19b2cc7443f23cce5f4828"}, + {file = "mypy-1.15.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:2922d42e16d6de288022e5ca321cd0618b238cfc5570e0263e5ba0a77dbef56f"}, + {file = "mypy-1.15.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2ee2d57e01a7c35de00f4634ba1bbf015185b219e4dc5909e281016df43f5ee5"}, + {file = "mypy-1.15.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:973500e0774b85d9689715feeffcc980193086551110fd678ebe1f4342fb7c5e"}, + {file = "mypy-1.15.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a95fb17c13e29d2d5195869262f8125dfdb5c134dc8d9a9d0aecf7525b10c2c"}, + {file = "mypy-1.15.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:1905f494bfd7d85a23a88c5d97840888a7bd516545fc5aaedff0267e0bb54e2f"}, + {file = "mypy-1.15.0-cp311-cp311-win_amd64.whl", hash = "sha256:c9817fa23833ff189db061e6d2eff49b2f3b6ed9856b4a0a73046e41932d744f"}, + {file = "mypy-1.15.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:aea39e0583d05124836ea645f412e88a5c7d0fd77a6d694b60d9b6b2d9f184fd"}, + {file = "mypy-1.15.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2f2147ab812b75e5b5499b01ade1f4a81489a147c01585cda36019102538615f"}, + {file = "mypy-1.15.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ce436f4c6d218a070048ed6a44c0bbb10cd2cc5e272b29e7845f6a2f57ee4464"}, + {file = "mypy-1.15.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8023ff13985661b50a5928fc7a5ca15f3d1affb41e5f0a9952cb68ef090b31ee"}, + {file = "mypy-1.15.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:1124a18bc11a6a62887e3e137f37f53fbae476dc36c185d549d4f837a2a6a14e"}, + {file = "mypy-1.15.0-cp312-cp312-win_amd64.whl", hash = "sha256:171a9ca9a40cd1843abeca0e405bc1940cd9b305eaeea2dda769ba096932bb22"}, + {file = "mypy-1.15.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:93faf3fdb04768d44bf28693293f3904bbb555d076b781ad2530214ee53e3445"}, + {file = "mypy-1.15.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:811aeccadfb730024c5d3e326b2fbe9249bb7413553f15499a4050f7c30e801d"}, + {file = "mypy-1.15.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:98b7b9b9aedb65fe628c62a6dc57f6d5088ef2dfca37903a7d9ee374d03acca5"}, + {file = "mypy-1.15.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c43a7682e24b4f576d93072216bf56eeff70d9140241f9edec0c104d0c515036"}, + {file = "mypy-1.15.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:baefc32840a9f00babd83251560e0ae1573e2f9d1b067719479bfb0e987c6357"}, + {file = "mypy-1.15.0-cp313-cp313-win_amd64.whl", hash = "sha256:b9378e2c00146c44793c98b8d5a61039a048e31f429fb0eb546d93f4b000bedf"}, + {file = "mypy-1.15.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:e601a7fa172c2131bff456bb3ee08a88360760d0d2f8cbd7a75a65497e2df078"}, + {file = "mypy-1.15.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:712e962a6357634fef20412699a3655c610110e01cdaa6180acec7fc9f8513ba"}, + {file = "mypy-1.15.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f95579473af29ab73a10bada2f9722856792a36ec5af5399b653aa28360290a5"}, + {file = "mypy-1.15.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8f8722560a14cde92fdb1e31597760dc35f9f5524cce17836c0d22841830fd5b"}, + {file = "mypy-1.15.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:1fbb8da62dc352133d7d7ca90ed2fb0e9d42bb1a32724c287d3c76c58cbaa9c2"}, + {file = "mypy-1.15.0-cp39-cp39-win_amd64.whl", hash = "sha256:d10d994b41fb3497719bbf866f227b3489048ea4bbbb5015357db306249f7980"}, + {file = "mypy-1.15.0-py3-none-any.whl", hash = "sha256:5469affef548bd1895d86d3bf10ce2b44e33d86923c29e4d675b3e323437ea3e"}, + {file = "mypy-1.15.0.tar.gz", hash = "sha256:404534629d51d3efea5c800ee7c42b72a6554d6c400e6a79eafe15d11341fd43"}, +] + +[package.dependencies] +mypy_extensions = ">=1.0.0" +typing_extensions = ">=4.6.0" + +[package.extras] +dmypy = ["psutil (>=4.0)"] +faster-cache = ["orjson"] +install-types = ["pip"] +mypyc = ["setuptools (>=50)"] +reports = ["lxml"] + +[[package]] +name = "mypy-extensions" +version = "1.0.0" +description = "Type system extensions for programs checked with the mypy type checker." +optional = false +python-versions = ">=3.5" +groups = ["main"] +files = [ + {file = "mypy_extensions-1.0.0-py3-none-any.whl", hash = "sha256:4392f6c0eb8a5668a69e23d168ffa70f0be9ccfd32b5cc2d26a34ae5b844552d"}, + {file = "mypy_extensions-1.0.0.tar.gz", hash = "sha256:75dbf8955dc00442a438fc4d0666508a9a97b6bd41aa2f0ffe9d2f2725af0782"}, +] + +[[package]] +name = "nodeenv" +version = "1.9.1" +description = "Node.js virtual environment builder" +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" +groups = ["main", "dev"] +files = [ + {file = "nodeenv-1.9.1-py2.py3-none-any.whl", hash = "sha256:ba11c9782d29c27c70ffbdda2d7415098754709be8a7056d79a737cd901155c9"}, + {file = "nodeenv-1.9.1.tar.gz", hash = "sha256:6ec12890a2dab7946721edbfbcd91f3319c6ccc9aec47be7c7e6b7011ee6645f"}, +] + +[[package]] +name = "packaging" +version = "24.2" +description = "Core utilities for Python packages" +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "packaging-24.2-py3-none-any.whl", hash = "sha256:09abb1bccd265c01f4a3aa3f7a7db064b36514d2cba19a2f694fe6150451a759"}, + {file = "packaging-24.2.tar.gz", hash = "sha256:c228a6dc5e932d346bc5739379109d49e8853dd8223571c7c5b55260edc0b97f"}, +] + +[[package]] +name = "pathspec" +version = "0.12.1" +description = "Utility library for gitignore style pattern matching of file paths." +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "pathspec-0.12.1-py3-none-any.whl", hash = "sha256:a0d503e138a4c123b27490a4f7beda6a01c6f288df0e4a8b79c7eb0dc7b4cc08"}, + {file = "pathspec-0.12.1.tar.gz", hash = "sha256:a482d51503a1ab33b1c67a6c3813a26953dbdc71c31dacaef9a838c4e29f5712"}, +] + +[[package]] +name = "platformdirs" +version = "4.3.7" +description = "A small Python package for determining appropriate platform-specific dirs, e.g. a `user data dir`." +optional = false +python-versions = ">=3.9" +groups = ["main", "dev"] +files = [ + {file = "platformdirs-4.3.7-py3-none-any.whl", hash = "sha256:a03875334331946f13c549dbd8f4bac7a13a50a895a0eb1e8c6a8ace80d40a94"}, + {file = "platformdirs-4.3.7.tar.gz", hash = "sha256:eb437d586b6a0986388f0d6f74aa0cde27b48d0e3d66843640bfb6bdcdb6e351"}, +] + +[package.extras] +docs = ["furo (>=2024.8.6)", "proselint (>=0.14)", "sphinx (>=8.1.3)", "sphinx-autodoc-typehints (>=3)"] +test = ["appdirs (==1.4.4)", "covdefaults (>=2.3)", "pytest (>=8.3.4)", "pytest-cov (>=6)", "pytest-mock (>=3.14)"] +type = ["mypy (>=1.14.1)"] + +[[package]] +name = "pre-commit" +version = "4.2.0" +description = "A framework for managing and maintaining multi-language pre-commit hooks." +optional = false +python-versions = ">=3.9" +groups = ["main", "dev"] +files = [ + {file = "pre_commit-4.2.0-py2.py3-none-any.whl", hash = "sha256:a009ca7205f1eb497d10b845e52c838a98b6cdd2102a6c8e4540e94ee75c58bd"}, + {file = "pre_commit-4.2.0.tar.gz", hash = "sha256:601283b9757afd87d40c4c4a9b2b5de9637a8ea02eaff7adc2d0fb4e04841146"}, +] + +[package.dependencies] +cfgv = ">=2.0.0" +identify = ">=1.0.0" +nodeenv = ">=0.11.1" +pyyaml = ">=5.1" +virtualenv = ">=20.10.0" + +[[package]] +name = "pycodestyle" +version = "2.13.0" +description = "Python style guide checker" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "pycodestyle-2.13.0-py2.py3-none-any.whl", hash = "sha256:35863c5974a271c7a726ed228a14a4f6daf49df369d8c50cd9a6f58a5e143ba9"}, + {file = "pycodestyle-2.13.0.tar.gz", hash = "sha256:c8415bf09abe81d9c7f872502a6eee881fbe85d8763dd5b9924bb0a01d67efae"}, +] + +[[package]] +name = "pydantic" +version = "2.11.3" +description = "Data validation using Python type hints" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "pydantic-2.11.3-py3-none-any.whl", hash = "sha256:a082753436a07f9ba1289c6ffa01cd93db3548776088aa917cc43b63f68fa60f"}, + {file = "pydantic-2.11.3.tar.gz", hash = "sha256:7471657138c16adad9322fe3070c0116dd6c3ad8d649300e3cbdfe91f4db4ec3"}, +] + +[package.dependencies] +annotated-types = ">=0.6.0" +pydantic-core = "2.33.1" +typing-extensions = ">=4.12.2" +typing-inspection = ">=0.4.0" + +[package.extras] +email = ["email-validator (>=2.0.0)"] +timezone = ["tzdata ; python_version >= \"3.9\" and platform_system == \"Windows\""] + +[[package]] +name = "pydantic-core" +version = "2.33.1" +description = "Core functionality for Pydantic validation and serialization" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "pydantic_core-2.33.1-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:3077cfdb6125cc8dab61b155fdd714663e401f0e6883f9632118ec12cf42df26"}, + {file = "pydantic_core-2.33.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8ffab8b2908d152e74862d276cf5017c81a2f3719f14e8e3e8d6b83fda863927"}, + {file = "pydantic_core-2.33.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5183e4f6a2d468787243ebcd70cf4098c247e60d73fb7d68d5bc1e1beaa0c4db"}, + {file = "pydantic_core-2.33.1-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:398a38d323f37714023be1e0285765f0a27243a8b1506b7b7de87b647b517e48"}, + {file = "pydantic_core-2.33.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:87d3776f0001b43acebfa86f8c64019c043b55cc5a6a2e313d728b5c95b46969"}, + {file = "pydantic_core-2.33.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c566dd9c5f63d22226409553531f89de0cac55397f2ab8d97d6f06cfce6d947e"}, + {file = "pydantic_core-2.33.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a0d5f3acc81452c56895e90643a625302bd6be351e7010664151cc55b7b97f89"}, + {file = "pydantic_core-2.33.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d3a07fadec2a13274a8d861d3d37c61e97a816beae717efccaa4b36dfcaadcde"}, + {file = "pydantic_core-2.33.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:f99aeda58dce827f76963ee87a0ebe75e648c72ff9ba1174a253f6744f518f65"}, + {file = "pydantic_core-2.33.1-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:902dbc832141aa0ec374f4310f1e4e7febeebc3256f00dc359a9ac3f264a45dc"}, + {file = "pydantic_core-2.33.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:fe44d56aa0b00d66640aa84a3cbe80b7a3ccdc6f0b1ca71090696a6d4777c091"}, + {file = "pydantic_core-2.33.1-cp310-cp310-win32.whl", hash = "sha256:ed3eb16d51257c763539bde21e011092f127a2202692afaeaccb50db55a31383"}, + {file = "pydantic_core-2.33.1-cp310-cp310-win_amd64.whl", hash = "sha256:694ad99a7f6718c1a498dc170ca430687a39894a60327f548e02a9c7ee4b6504"}, + {file = "pydantic_core-2.33.1-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:6e966fc3caaf9f1d96b349b0341c70c8d6573bf1bac7261f7b0ba88f96c56c24"}, + {file = "pydantic_core-2.33.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:bfd0adeee563d59c598ceabddf2c92eec77abcb3f4a391b19aa7366170bd9e30"}, + {file = "pydantic_core-2.33.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:91815221101ad3c6b507804178a7bb5cb7b2ead9ecd600041669c8d805ebd595"}, + {file = "pydantic_core-2.33.1-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9fea9c1869bb4742d174a57b4700c6dadea951df8b06de40c2fedb4f02931c2e"}, + {file = "pydantic_core-2.33.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1d20eb4861329bb2484c021b9d9a977566ab16d84000a57e28061151c62b349a"}, + {file = "pydantic_core-2.33.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0fb935c5591573ae3201640579f30128ccc10739b45663f93c06796854405505"}, + {file = "pydantic_core-2.33.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c964fd24e6166420d18fb53996d8c9fd6eac9bf5ae3ec3d03015be4414ce497f"}, + {file = "pydantic_core-2.33.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:681d65e9011f7392db5aa002b7423cc442d6a673c635668c227c6c8d0e5a4f77"}, + {file = "pydantic_core-2.33.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:e100c52f7355a48413e2999bfb4e139d2977a904495441b374f3d4fb4a170961"}, + {file = "pydantic_core-2.33.1-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:048831bd363490be79acdd3232f74a0e9951b11b2b4cc058aeb72b22fdc3abe1"}, + {file = "pydantic_core-2.33.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:bdc84017d28459c00db6f918a7272a5190bec3090058334e43a76afb279eac7c"}, + {file = "pydantic_core-2.33.1-cp311-cp311-win32.whl", hash = "sha256:32cd11c5914d1179df70406427097c7dcde19fddf1418c787540f4b730289896"}, + {file = "pydantic_core-2.33.1-cp311-cp311-win_amd64.whl", hash = "sha256:2ea62419ba8c397e7da28a9170a16219d310d2cf4970dbc65c32faf20d828c83"}, + {file = "pydantic_core-2.33.1-cp311-cp311-win_arm64.whl", hash = "sha256:fc903512177361e868bc1f5b80ac8c8a6e05fcdd574a5fb5ffeac5a9982b9e89"}, + {file = "pydantic_core-2.33.1-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:1293d7febb995e9d3ec3ea09caf1a26214eec45b0f29f6074abb004723fc1de8"}, + {file = "pydantic_core-2.33.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:99b56acd433386c8f20be5c4000786d1e7ca0523c8eefc995d14d79c7a081498"}, + {file = "pydantic_core-2.33.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:35a5ec3fa8c2fe6c53e1b2ccc2454398f95d5393ab398478f53e1afbbeb4d939"}, + {file = "pydantic_core-2.33.1-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b172f7b9d2f3abc0efd12e3386f7e48b576ef309544ac3a63e5e9cdd2e24585d"}, + {file = "pydantic_core-2.33.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9097b9f17f91eea659b9ec58148c0747ec354a42f7389b9d50701610d86f812e"}, + {file = "pydantic_core-2.33.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cc77ec5b7e2118b152b0d886c7514a4653bcb58c6b1d760134a9fab915f777b3"}, + {file = "pydantic_core-2.33.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d5e3d15245b08fa4a84cefc6c9222e6f37c98111c8679fbd94aa145f9a0ae23d"}, + {file = "pydantic_core-2.33.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:ef99779001d7ac2e2461d8ab55d3373fe7315caefdbecd8ced75304ae5a6fc6b"}, + {file = "pydantic_core-2.33.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:fc6bf8869e193855e8d91d91f6bf59699a5cdfaa47a404e278e776dd7f168b39"}, + {file = "pydantic_core-2.33.1-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:b1caa0bc2741b043db7823843e1bde8aaa58a55a58fda06083b0569f8b45693a"}, + {file = "pydantic_core-2.33.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:ec259f62538e8bf364903a7d0d0239447059f9434b284f5536e8402b7dd198db"}, + {file = "pydantic_core-2.33.1-cp312-cp312-win32.whl", hash = "sha256:e14f369c98a7c15772b9da98987f58e2b509a93235582838bd0d1d8c08b68fda"}, + {file = "pydantic_core-2.33.1-cp312-cp312-win_amd64.whl", hash = "sha256:1c607801d85e2e123357b3893f82c97a42856192997b95b4d8325deb1cd0c5f4"}, + {file = "pydantic_core-2.33.1-cp312-cp312-win_arm64.whl", hash = "sha256:8d13f0276806ee722e70a1c93da19748594f19ac4299c7e41237fc791d1861ea"}, + {file = "pydantic_core-2.33.1-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:70af6a21237b53d1fe7b9325b20e65cbf2f0a848cf77bed492b029139701e66a"}, + {file = "pydantic_core-2.33.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:282b3fe1bbbe5ae35224a0dbd05aed9ccabccd241e8e6b60370484234b456266"}, + {file = "pydantic_core-2.33.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4b315e596282bbb5822d0c7ee9d255595bd7506d1cb20c2911a4da0b970187d3"}, + {file = "pydantic_core-2.33.1-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:1dfae24cf9921875ca0ca6a8ecb4bb2f13c855794ed0d468d6abbec6e6dcd44a"}, + {file = "pydantic_core-2.33.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6dd8ecfde08d8bfadaea669e83c63939af76f4cf5538a72597016edfa3fad516"}, + {file = "pydantic_core-2.33.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2f593494876eae852dc98c43c6f260f45abdbfeec9e4324e31a481d948214764"}, + {file = "pydantic_core-2.33.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:948b73114f47fd7016088e5186d13faf5e1b2fe83f5e320e371f035557fd264d"}, + {file = "pydantic_core-2.33.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:e11f3864eb516af21b01e25fac915a82e9ddad3bb0fb9e95a246067398b435a4"}, + {file = "pydantic_core-2.33.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:549150be302428b56fdad0c23c2741dcdb5572413776826c965619a25d9c6bde"}, + {file = "pydantic_core-2.33.1-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:495bc156026efafd9ef2d82372bd38afce78ddd82bf28ef5276c469e57c0c83e"}, + {file = "pydantic_core-2.33.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:ec79de2a8680b1a67a07490bddf9636d5c2fab609ba8c57597e855fa5fa4dacd"}, + {file = "pydantic_core-2.33.1-cp313-cp313-win32.whl", hash = "sha256:ee12a7be1742f81b8a65b36c6921022301d466b82d80315d215c4c691724986f"}, + {file = "pydantic_core-2.33.1-cp313-cp313-win_amd64.whl", hash = "sha256:ede9b407e39949d2afc46385ce6bd6e11588660c26f80576c11c958e6647bc40"}, + {file = "pydantic_core-2.33.1-cp313-cp313-win_arm64.whl", hash = "sha256:aa687a23d4b7871a00e03ca96a09cad0f28f443690d300500603bd0adba4b523"}, + {file = "pydantic_core-2.33.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:401d7b76e1000d0dd5538e6381d28febdcacb097c8d340dde7d7fc6e13e9f95d"}, + {file = "pydantic_core-2.33.1-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7aeb055a42d734c0255c9e489ac67e75397d59c6fbe60d155851e9782f276a9c"}, + {file = "pydantic_core-2.33.1-cp313-cp313t-win_amd64.whl", hash = "sha256:338ea9b73e6e109f15ab439e62cb3b78aa752c7fd9536794112e14bee02c8d18"}, + {file = "pydantic_core-2.33.1-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:5ab77f45d33d264de66e1884fca158bc920cb5e27fd0764a72f72f5756ae8bdb"}, + {file = "pydantic_core-2.33.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:e7aaba1b4b03aaea7bb59e1b5856d734be011d3e6d98f5bcaa98cb30f375f2ad"}, + {file = "pydantic_core-2.33.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7fb66263e9ba8fea2aa85e1e5578980d127fb37d7f2e292773e7bc3a38fb0c7b"}, + {file = "pydantic_core-2.33.1-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3f2648b9262607a7fb41d782cc263b48032ff7a03a835581abbf7a3bec62bcf5"}, + {file = "pydantic_core-2.33.1-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:723c5630c4259400818b4ad096735a829074601805d07f8cafc366d95786d331"}, + {file = "pydantic_core-2.33.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d100e3ae783d2167782391e0c1c7a20a31f55f8015f3293647544df3f9c67824"}, + {file = "pydantic_core-2.33.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:177d50460bc976a0369920b6c744d927b0ecb8606fb56858ff542560251b19e5"}, + {file = "pydantic_core-2.33.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a3edde68d1a1f9af1273b2fe798997b33f90308fb6d44d8550c89fc6a3647cf6"}, + {file = "pydantic_core-2.33.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:a62c3c3ef6a7e2c45f7853b10b5bc4ddefd6ee3cd31024754a1a5842da7d598d"}, + {file = "pydantic_core-2.33.1-cp39-cp39-musllinux_1_1_armv7l.whl", hash = "sha256:c91dbb0ab683fa0cd64a6e81907c8ff41d6497c346890e26b23de7ee55353f96"}, + {file = "pydantic_core-2.33.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:9f466e8bf0a62dc43e068c12166281c2eca72121dd2adc1040f3aa1e21ef8599"}, + {file = "pydantic_core-2.33.1-cp39-cp39-win32.whl", hash = "sha256:ab0277cedb698749caada82e5d099dc9fed3f906a30d4c382d1a21725777a1e5"}, + {file = "pydantic_core-2.33.1-cp39-cp39-win_amd64.whl", hash = "sha256:5773da0ee2d17136b1f1c6fbde543398d452a6ad2a7b54ea1033e2daa739b8d2"}, + {file = "pydantic_core-2.33.1-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:5c834f54f8f4640fd7e4b193f80eb25a0602bba9e19b3cd2fc7ffe8199f5ae02"}, + {file = "pydantic_core-2.33.1-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:049e0de24cf23766f12cc5cc71d8abc07d4a9deb9061b334b62093dedc7cb068"}, + {file = "pydantic_core-2.33.1-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1a28239037b3d6f16916a4c831a5a0eadf856bdd6d2e92c10a0da3a59eadcf3e"}, + {file = "pydantic_core-2.33.1-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9d3da303ab5f378a268fa7d45f37d7d85c3ec19769f28d2cc0c61826a8de21fe"}, + {file = "pydantic_core-2.33.1-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:25626fb37b3c543818c14821afe0fd3830bc327a43953bc88db924b68c5723f1"}, + {file = "pydantic_core-2.33.1-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:3ab2d36e20fbfcce8f02d73c33a8a7362980cff717926bbae030b93ae46b56c7"}, + {file = "pydantic_core-2.33.1-pp310-pypy310_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:2f9284e11c751b003fd4215ad92d325d92c9cb19ee6729ebd87e3250072cdcde"}, + {file = "pydantic_core-2.33.1-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:048c01eee07d37cbd066fc512b9d8b5ea88ceeb4e629ab94b3e56965ad655add"}, + {file = "pydantic_core-2.33.1-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:5ccd429694cf26af7997595d627dd2637e7932214486f55b8a357edaac9dae8c"}, + {file = "pydantic_core-2.33.1-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:3a371dc00282c4b84246509a5ddc808e61b9864aa1eae9ecc92bb1268b82db4a"}, + {file = "pydantic_core-2.33.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:f59295ecc75a1788af8ba92f2e8c6eeaa5a94c22fc4d151e8d9638814f85c8fc"}, + {file = "pydantic_core-2.33.1-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:08530b8ac922003033f399128505f513e30ca770527cc8bbacf75a84fcc2c74b"}, + {file = "pydantic_core-2.33.1-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bae370459da6a5466978c0eacf90690cb57ec9d533f8e63e564ef3822bfa04fe"}, + {file = "pydantic_core-2.33.1-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:e3de2777e3b9f4d603112f78006f4ae0acb936e95f06da6cb1a45fbad6bdb4b5"}, + {file = "pydantic_core-2.33.1-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:3a64e81e8cba118e108d7126362ea30e021291b7805d47e4896e52c791be2761"}, + {file = "pydantic_core-2.33.1-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:52928d8c1b6bda03cc6d811e8923dffc87a2d3c8b3bfd2ce16471c7147a24850"}, + {file = "pydantic_core-2.33.1-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:1b30d92c9412beb5ac6b10a3eb7ef92ccb14e3f2a8d7732e2d739f58b3aa7544"}, + {file = "pydantic_core-2.33.1-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:f995719707e0e29f0f41a8aa3bcea6e761a36c9136104d3189eafb83f5cec5e5"}, + {file = "pydantic_core-2.33.1-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:7edbc454a29fc6aeae1e1eecba4f07b63b8d76e76a748532233c4c167b4cb9ea"}, + {file = "pydantic_core-2.33.1-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:ad05b683963f69a1d5d2c2bdab1274a31221ca737dbbceaa32bcb67359453cdd"}, + {file = "pydantic_core-2.33.1-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:df6a94bf9452c6da9b5d76ed229a5683d0306ccb91cca8e1eea883189780d568"}, + {file = "pydantic_core-2.33.1-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7965c13b3967909a09ecc91f21d09cfc4576bf78140b988904e94f130f188396"}, + {file = "pydantic_core-2.33.1-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:3f1fdb790440a34f6ecf7679e1863b825cb5ffde858a9197f851168ed08371e5"}, + {file = "pydantic_core-2.33.1-pp39-pypy39_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:5277aec8d879f8d05168fdd17ae811dd313b8ff894aeeaf7cd34ad28b4d77e33"}, + {file = "pydantic_core-2.33.1-pp39-pypy39_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:8ab581d3530611897d863d1a649fb0644b860286b4718db919bfd51ece41f10b"}, + {file = "pydantic_core-2.33.1-pp39-pypy39_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:0483847fa9ad5e3412265c1bd72aad35235512d9ce9d27d81a56d935ef489672"}, + {file = "pydantic_core-2.33.1-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:de9e06abe3cc5ec6a2d5f75bc99b0bdca4f5c719a5b34026f8c57efbdecd2ee3"}, + {file = "pydantic_core-2.33.1.tar.gz", hash = "sha256:bcc9c6fdb0ced789245b02b7d6603e17d1563064ddcfc36f046b61c0c05dd9df"}, +] + +[package.dependencies] +typing-extensions = ">=4.6.0,<4.7.0 || >4.7.0" + +[[package]] +name = "pyflakes" +version = "3.3.2" +description = "passive checker of Python programs" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "pyflakes-3.3.2-py2.py3-none-any.whl", hash = "sha256:5039c8339cbb1944045f4ee5466908906180f13cc99cc9949348d10f82a5c32a"}, + {file = "pyflakes-3.3.2.tar.gz", hash = "sha256:6dfd61d87b97fba5dcfaaf781171ac16be16453be6d816147989e7f6e6a9576b"}, +] + +[[package]] +name = "pyyaml" +version = "6.0.2" +description = "YAML parser and emitter for Python" +optional = false +python-versions = ">=3.8" +groups = ["main", "dev"] +files = [ + {file = "PyYAML-6.0.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0a9a2848a5b7feac301353437eb7d5957887edbf81d56e903999a75a3d743086"}, + {file = "PyYAML-6.0.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:29717114e51c84ddfba879543fb232a6ed60086602313ca38cce623c1d62cfbf"}, + {file = "PyYAML-6.0.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8824b5a04a04a047e72eea5cec3bc266db09e35de6bdfe34c9436ac5ee27d237"}, + {file = "PyYAML-6.0.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7c36280e6fb8385e520936c3cb3b8042851904eba0e58d277dca80a5cfed590b"}, + {file = "PyYAML-6.0.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ec031d5d2feb36d1d1a24380e4db6d43695f3748343d99434e6f5f9156aaa2ed"}, + {file = "PyYAML-6.0.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:936d68689298c36b53b29f23c6dbb74de12b4ac12ca6cfe0e047bedceea56180"}, + {file = "PyYAML-6.0.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:23502f431948090f597378482b4812b0caae32c22213aecf3b55325e049a6c68"}, + {file = "PyYAML-6.0.2-cp310-cp310-win32.whl", hash = "sha256:2e99c6826ffa974fe6e27cdb5ed0021786b03fc98e5ee3c5bfe1fd5015f42b99"}, + {file = "PyYAML-6.0.2-cp310-cp310-win_amd64.whl", hash = "sha256:a4d3091415f010369ae4ed1fc6b79def9416358877534caf6a0fdd2146c87a3e"}, + {file = "PyYAML-6.0.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:cc1c1159b3d456576af7a3e4d1ba7e6924cb39de8f67111c735f6fc832082774"}, + {file = "PyYAML-6.0.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1e2120ef853f59c7419231f3bf4e7021f1b936f6ebd222406c3b60212205d2ee"}, + {file = "PyYAML-6.0.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5d225db5a45f21e78dd9358e58a98702a0302f2659a3c6cd320564b75b86f47c"}, + {file = "PyYAML-6.0.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5ac9328ec4831237bec75defaf839f7d4564be1e6b25ac710bd1a96321cc8317"}, + {file = "PyYAML-6.0.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3ad2a3decf9aaba3d29c8f537ac4b243e36bef957511b4766cb0057d32b0be85"}, + {file = "PyYAML-6.0.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:ff3824dc5261f50c9b0dfb3be22b4567a6f938ccce4587b38952d85fd9e9afe4"}, + {file = "PyYAML-6.0.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:797b4f722ffa07cc8d62053e4cff1486fa6dc094105d13fea7b1de7d8bf71c9e"}, + {file = "PyYAML-6.0.2-cp311-cp311-win32.whl", hash = "sha256:11d8f3dd2b9c1207dcaf2ee0bbbfd5991f571186ec9cc78427ba5bd32afae4b5"}, + {file = "PyYAML-6.0.2-cp311-cp311-win_amd64.whl", hash = "sha256:e10ce637b18caea04431ce14fabcf5c64a1c61ec9c56b071a4b7ca131ca52d44"}, + {file = "PyYAML-6.0.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:c70c95198c015b85feafc136515252a261a84561b7b1d51e3384e0655ddf25ab"}, + {file = "PyYAML-6.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ce826d6ef20b1bc864f0a68340c8b3287705cae2f8b4b1d932177dcc76721725"}, + {file = "PyYAML-6.0.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1f71ea527786de97d1a0cc0eacd1defc0985dcf6b3f17bb77dcfc8c34bec4dc5"}, + {file = "PyYAML-6.0.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9b22676e8097e9e22e36d6b7bda33190d0d400f345f23d4065d48f4ca7ae0425"}, + {file = "PyYAML-6.0.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:80bab7bfc629882493af4aa31a4cfa43a4c57c83813253626916b8c7ada83476"}, + {file = "PyYAML-6.0.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:0833f8694549e586547b576dcfaba4a6b55b9e96098b36cdc7ebefe667dfed48"}, + {file = "PyYAML-6.0.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8b9c7197f7cb2738065c481a0461e50ad02f18c78cd75775628afb4d7137fb3b"}, + {file = "PyYAML-6.0.2-cp312-cp312-win32.whl", hash = "sha256:ef6107725bd54b262d6dedcc2af448a266975032bc85ef0172c5f059da6325b4"}, + {file = "PyYAML-6.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:7e7401d0de89a9a855c839bc697c079a4af81cf878373abd7dc625847d25cbd8"}, + {file = "PyYAML-6.0.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:efdca5630322a10774e8e98e1af481aad470dd62c3170801852d752aa7a783ba"}, + {file = "PyYAML-6.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:50187695423ffe49e2deacb8cd10510bc361faac997de9efef88badc3bb9e2d1"}, + {file = "PyYAML-6.0.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0ffe8360bab4910ef1b9e87fb812d8bc0a308b0d0eef8c8f44e0254ab3b07133"}, + {file = "PyYAML-6.0.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:17e311b6c678207928d649faa7cb0d7b4c26a0ba73d41e99c4fff6b6c3276484"}, + {file = "PyYAML-6.0.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:70b189594dbe54f75ab3a1acec5f1e3faa7e8cf2f1e08d9b561cb41b845f69d5"}, + {file = "PyYAML-6.0.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:41e4e3953a79407c794916fa277a82531dd93aad34e29c2a514c2c0c5fe971cc"}, + {file = "PyYAML-6.0.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:68ccc6023a3400877818152ad9a1033e3db8625d899c72eacb5a668902e4d652"}, + {file = "PyYAML-6.0.2-cp313-cp313-win32.whl", hash = "sha256:bc2fa7c6b47d6bc618dd7fb02ef6fdedb1090ec036abab80d4681424b84c1183"}, + {file = "PyYAML-6.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:8388ee1976c416731879ac16da0aff3f63b286ffdd57cdeb95f3f2e085687563"}, + {file = "PyYAML-6.0.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:24471b829b3bf607e04e88d79542a9d48bb037c2267d7927a874e6c205ca7e9a"}, + {file = "PyYAML-6.0.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d7fded462629cfa4b685c5416b949ebad6cec74af5e2d42905d41e257e0869f5"}, + {file = "PyYAML-6.0.2-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d84a1718ee396f54f3a086ea0a66d8e552b2ab2017ef8b420e92edbc841c352d"}, + {file = "PyYAML-6.0.2-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9056c1ecd25795207ad294bcf39f2db3d845767be0ea6e6a34d856f006006083"}, + {file = "PyYAML-6.0.2-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:82d09873e40955485746739bcb8b4586983670466c23382c19cffecbf1fd8706"}, + {file = "PyYAML-6.0.2-cp38-cp38-win32.whl", hash = "sha256:43fa96a3ca0d6b1812e01ced1044a003533c47f6ee8aca31724f78e93ccc089a"}, + {file = "PyYAML-6.0.2-cp38-cp38-win_amd64.whl", hash = "sha256:01179a4a8559ab5de078078f37e5c1a30d76bb88519906844fd7bdea1b7729ff"}, + {file = "PyYAML-6.0.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:688ba32a1cffef67fd2e9398a2efebaea461578b0923624778664cc1c914db5d"}, + {file = "PyYAML-6.0.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:a8786accb172bd8afb8be14490a16625cbc387036876ab6ba70912730faf8e1f"}, + {file = "PyYAML-6.0.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d8e03406cac8513435335dbab54c0d385e4a49e4945d2909a581c83647ca0290"}, + {file = "PyYAML-6.0.2-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f753120cb8181e736c57ef7636e83f31b9c0d1722c516f7e86cf15b7aa57ff12"}, + {file = "PyYAML-6.0.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3b1fdb9dc17f5a7677423d508ab4f243a726dea51fa5e70992e59a7411c89d19"}, + {file = "PyYAML-6.0.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:0b69e4ce7a131fe56b7e4d770c67429700908fc0752af059838b1cfb41960e4e"}, + {file = "PyYAML-6.0.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:a9f8c2e67970f13b16084e04f134610fd1d374bf477b17ec1599185cf611d725"}, + {file = "PyYAML-6.0.2-cp39-cp39-win32.whl", hash = "sha256:6395c297d42274772abc367baaa79683958044e5d3835486c16da75d2a694631"}, + {file = "PyYAML-6.0.2-cp39-cp39-win_amd64.whl", hash = "sha256:39693e1f8320ae4f43943590b49779ffb98acb81f788220ea932a6b6c51004d8"}, + {file = "pyyaml-6.0.2.tar.gz", hash = "sha256:d584d9ec91ad65861cc08d42e834324ef890a082e591037abe114850ff7bbc3e"}, +] + +[[package]] +name = "typing-extensions" +version = "4.13.2" +description = "Backported and Experimental Type Hints for Python 3.8+" +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "typing_extensions-4.13.2-py3-none-any.whl", hash = "sha256:a439e7c04b49fec3e5d3e2beaa21755cadbbdc391694e28ccdd36ca4a1408f8c"}, + {file = "typing_extensions-4.13.2.tar.gz", hash = "sha256:e6c81219bd689f51865d9e372991c540bda33a0379d5573cddb9a3a23f7caaef"}, +] + +[[package]] +name = "typing-inspection" +version = "0.4.0" +description = "Runtime typing introspection tools" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "typing_inspection-0.4.0-py3-none-any.whl", hash = "sha256:50e72559fcd2a6367a19f7a7e610e6afcb9fac940c650290eed893d61386832f"}, + {file = "typing_inspection-0.4.0.tar.gz", hash = "sha256:9765c87de36671694a67904bf2c96e395be9c6439bb6c87b5142569dcdd65122"}, +] + +[package.dependencies] +typing-extensions = ">=4.12.0" + +[[package]] +name = "virtualenv" +version = "20.30.0" +description = "Virtual Python Environment builder" +optional = false +python-versions = ">=3.8" +groups = ["main", "dev"] +files = [ + {file = "virtualenv-20.30.0-py3-none-any.whl", hash = "sha256:e34302959180fca3af42d1800df014b35019490b119eba981af27f2fa486e5d6"}, + {file = "virtualenv-20.30.0.tar.gz", hash = "sha256:800863162bcaa5450a6e4d721049730e7f2dae07720e0902b0e4040bd6f9ada8"}, +] + +[package.dependencies] +distlib = ">=0.3.7,<1" +filelock = ">=3.12.2,<4" +platformdirs = ">=3.9.1,<5" + +[package.extras] +docs = ["furo (>=2023.7.26)", "proselint (>=0.13)", "sphinx (>=7.1.2,!=7.3)", "sphinx-argparse (>=0.4)", "sphinxcontrib-towncrier (>=0.2.1a0)", "towncrier (>=23.6)"] +test = ["covdefaults (>=2.3)", "coverage (>=7.2.7)", "coverage-enable-subprocess (>=1)", "flaky (>=3.7)", "packaging (>=23.1)", "pytest (>=7.4)", "pytest-env (>=0.8.2)", "pytest-freezer (>=0.4.8) ; platform_python_implementation == \"PyPy\" or platform_python_implementation == \"GraalVM\" or platform_python_implementation == \"CPython\" and sys_platform == \"win32\" and python_version >= \"3.13\"", "pytest-mock (>=3.11.1)", "pytest-randomly (>=3.12)", "pytest-timeout (>=2.1)", "setuptools (>=68)", "time-machine (>=2.10) ; platform_python_implementation == \"CPython\""] + +[metadata] +lock-version = "2.1" +python-versions = ">=3.12, <3.14" +content-hash = "bbf3d4328e042f797630fcdc27fcd3547247c651ffab53c7747dae2c43141a32" diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..3166477 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,44 @@ +[tool.poetry] +name = "Dots Game" +version = "0.0.1" +description = "Классическая игра 'Точки' для двух игроков с простым интерфейсом и стратегическим геймплеем. Захватывайте территории, окружая точки соперника, и побеждайте!" +authors = [ + "Samorodova Aleksandra", + "Nazarova Inessa", ] + +package-mode = false + +[tool.poetry.dependencies] +python = ">=3.12, <3.14" +mypy = "^1.15.0" +black = "^25.1.0" +flake8 = "^7.2.0" +isort = "^6.0.1" +pre-commit = "^4.2.0" +pydantic = "^2.11.3" + +[tool.poetry.group.dev.dependencies] +pre-commit = "^4.2.0" + +[tool.black] +line-length = 120 +target-version = ['py312'] +skip-string-normalization = true + +[tool.isort] +profile = "black" +line_length = 120 +known_first_party = ["dots"] +sections = ["FUTURE", "STDLIB", "THIRDPARTY", "FIRSTPARTY", "LOCALFOLDER"] +default_section = "THIRDPARTY" +skip = [".venv", "venv", "build", "dist", "migrations"] +force_sort_within_sections = true +lines_after_imports = 2 +combine_as_imports = true +include_trailing_comma = true + +[tool.flake8] +max-line-length = 120 +ignore = "E203, W503" +exclude = ".git, __pycache__, .venv" +show-source = true From 64a62aab01dd1c83b411bbea413cf1253fcaa3a5 Mon Sep 17 00:00:00 2001 From: SN <2005kan@mail.ru> Date: Fri, 2 May 2025 13:45:48 +0500 Subject: [PATCH 2/3] v9 --- .flake8 | 21 +++++++ .pre-commit-config.yaml | 2 - dots/game_logic.py | 135 ++++++++++++++++++++++++++-------------- poetry.lock | 10 +-- pyproject.toml | 1 + 5 files changed, 114 insertions(+), 55 deletions(-) create mode 100644 .flake8 diff --git a/.flake8 b/.flake8 new file mode 100644 index 0000000..ff1ad85 --- /dev/null +++ b/.flake8 @@ -0,0 +1,21 @@ +[flake8] +max-line-length = 120 + +ignore = + E203, + W503, + E501, + +exclude = + .git, + __pycache__, + .venv, + .mypy_cache, + build, + dist, + migrations, + venv + +filename = *.py + +max-complexity = 12 diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 2ab8571..26408c5 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -20,8 +20,6 @@ repos: rev: 23.10.1 hooks: - id: black - args: - - "--config=pyproject.toml" - repo: https://github.com/PyCQA/flake8 rev: 6.1.0 diff --git a/dots/game_logic.py b/dots/game_logic.py index 45e6c1d..c0d2768 100644 --- a/dots/game_logic.py +++ b/dots/game_logic.py @@ -53,25 +53,40 @@ def is_valid_move(self, grid_x: int, grid_y: int) -> bool: def place_dot(self, grid_x: int, grid_y: int) -> bool: """Помещает точку игрока на поле.""" - if not self.is_valid_move(grid_x, grid_y): + if not self._validate_move(grid_x, grid_y): return False - if self._grid[grid_y][grid_x] != self.EMPTY_CELL: - return False - - self._grid[grid_y][grid_x] = self.current_player - self._filled_cells += 1 + self._execute_move(grid_x, grid_y) if self._is_board_full(): return True self._check_neighbors(grid_x, grid_y) + self._handle_captures(grid_x, grid_y) + self._check_self_capture(grid_x, grid_y) + + return self._is_board_full() + + def _validate_move(self, grid_x: int, grid_y: int) -> bool: + """Validate if move is possible.""" + if not (0 <= grid_x <= self._grid_width and 0 <= grid_y <= self._grid_height): + return False + return self._grid[grid_y][grid_x] == self.EMPTY_CELL + + def _execute_move(self, grid_x: int, grid_y: int) -> None: + """Execute the move on the grid.""" + self._grid[grid_y][grid_x] = self.current_player + self._filled_cells += 1 + def _handle_captures(self, grid_x: int, grid_y: int) -> None: + """Handle opponent captures after move.""" captures = self._check_captures(grid_x, grid_y) if captures > self.INITIAL_FILLED_CELLS: self.scores[self.current_player] += captures self._game_ui.update_scoreboard() + def _check_self_capture(self, grid_x: int, grid_y: int) -> None: + """Check if move causes self-capture.""" opponent = self.PLAYER_2 if self.current_player == self.PLAYER_1 else self.PLAYER_1 for dx, dy in self.FOUR_DIRECTIONS: nx, ny = grid_x + dx, grid_y + dy @@ -79,17 +94,11 @@ def place_dot(self, grid_x: int, grid_y: int) -> bool: if self._grid[ny][nx] == self.current_player: group, is_surrounded = self._get_group_with_status(nx, ny, self.current_player) if is_surrounded: - self_captures = 0 - for gx, gy in group: - if self._grid[gy][gx] == self.current_player: - self_captures += 1 - + self_captures = sum(1 for gx, gy in group if self._grid[gy][gx] == self.current_player) if self_captures > 0: self.scores[opponent] += self_captures self._game_ui.update_scoreboard() - return self._is_board_full() - def _check_neighbors(self, grid_x: int, grid_y: int) -> None: """ Проверяет соседей текущей клетки и добавляет линии между соседними @@ -181,59 +190,89 @@ def make_ai_move_random(self) -> tuple[int, int] | None: return None def make_ai_move_smart(self) -> tuple[int, int] | None: - """ - Умный ИИ: сначала пытается окружить точки игрока, затем делает ход рядом - с ними. - """ + """Smart AI move: tries to capture player dots first, then plays strategically.""" + capture_move = self._find_capture_move() + if capture_move: + return self._execute_ai_move(capture_move[0], capture_move[1]) + + strategic_move = self._find_strategic_move() + if strategic_move: + return self._execute_ai_move(strategic_move[0], strategic_move[1]) + + return self.make_ai_move_random() + + def _find_capture_move(self) -> tuple[int, int] | None: + """Find a move that would capture player dots.""" for y in range(self._grid_height + 1): for x in range(self._grid_width + 1): - if self.is_valid_move(x, y): - if self._would_capture_player(x, y): - if self.place_dot(x, y): - return None - return x, y - player_dots = [] + if self.is_valid_move(x, y) and self._would_capture_player(x, y): + return x, y + return None + + def _find_strategic_move(self) -> tuple[int, int] | None: + """Find a strategic move near player dots.""" + player_dots = self._get_player_dots() + if not player_dots: + return None + + candidate_moves = self._get_candidate_moves(player_dots) + if not candidate_moves: + return None + + return self._select_best_move(candidate_moves) + + def _get_player_dots(self) -> list[tuple[int, int]]: + """Get all player dots on the board.""" + dots = [] for y in range(self._grid_height + 1): for x in range(self._grid_width + 1): if self._grid[y][x] == self.PLAYER_1: - player_dots.append((x, y)) - - if not player_dots: - return self.make_ai_move_random() + dots.append((x, y)) + return dots - candidate_moves = [] + def _get_candidate_moves(self, player_dots: list[tuple[int, int]]) -> list[tuple[int, int]]: + """Get all valid moves near player dots.""" + candidates = [] for x, y in player_dots: for dx, dy in self.EIGHT_DIRECTIONS: nx, ny = x + dx, y + dy if 0 <= nx <= self._grid_width and 0 <= ny <= self._grid_height: if self.is_valid_move(nx, ny): - candidate_moves.append((nx, ny)) + candidates.append((nx, ny)) + return candidates - if not candidate_moves: - return self.make_ai_move_random() - best_move = None - best_score = -1 + def _select_best_move(self, candidates: list[tuple[int, int]]) -> tuple[int, int]: + """Select best move from candidates based on scoring. If no candidates, return a random move.""" + if not candidates: + return self.make_ai_move_random() or (0, 0) - for x, y in candidate_moves: - score = 0 - for dx, dy in self.EIGHT_DIRECTIONS: - nx, ny = x + dx, y + dy - if 0 <= nx <= self._grid_width and 0 <= ny <= self._grid_height: - if self._grid[ny][nx] == self.PLAYER_1: - score += 3 - elif self._grid[ny][nx] == self.PLAYER_2: - score += 1 + best_move = candidates[0] + best_score = -1 + for x, y in candidates: + score = self._calculate_move_score(x, y) if score > best_score or (score == best_score and random.random() < 0.5): best_score = score best_move = (x, y) + return best_move - if best_move: - if self.place_dot(best_move[0], best_move[1]): - return None - return best_move - - return self.make_ai_move_random() + def _calculate_move_score(self, x: int, y: int) -> int: + """Calculate score for a potential move.""" + score = 0 + for dx, dy in self.EIGHT_DIRECTIONS: + nx, ny = x + dx, y + dy + if 0 <= nx <= self._grid_width and 0 <= ny <= self._grid_height: + if self._grid[ny][nx] == self.PLAYER_1: + score += 3 + elif self._grid[ny][nx] == self.PLAYER_2: + score += 1 + return score + + def _execute_ai_move(self, x: int, y: int) -> tuple[int, int] | None: + """Execute the AI move and return coordinates or None if game ends.""" + if self.place_dot(x, y): + return None + return x, y def _would_capture_player(self, x: int, y: int) -> bool: """Проверяет, приведет ли ход к захвату области игрока.""" diff --git a/poetry.lock b/poetry.lock index 117e271..9e64b70 100644 --- a/poetry.lock +++ b/poetry.lock @@ -132,7 +132,7 @@ version = "7.2.0" description = "the modular source code checker: pep8 pyflakes and co" optional = false python-versions = ">=3.9" -groups = ["main"] +groups = ["main", "dev"] files = [ {file = "flake8-7.2.0-py2.py3-none-any.whl", hash = "sha256:93b92ba5bdb60754a6da14fa3b93a9361fd00a59632ada61fd7b130436c40343"}, {file = "flake8-7.2.0.tar.gz", hash = "sha256:fa558ae3f6f7dbf2b4f22663e5343b6b6023620461f8d4ff2019ef4b5ee70426"}, @@ -180,7 +180,7 @@ version = "0.7.0" description = "McCabe checker, plugin for flake8" optional = false python-versions = ">=3.6" -groups = ["main"] +groups = ["main", "dev"] files = [ {file = "mccabe-0.7.0-py2.py3-none-any.whl", hash = "sha256:6c2d30ab6be0e4a46919781807b4f0d834ebdd6c6e3dca0bda5a15f863427b6e"}, {file = "mccabe-0.7.0.tar.gz", hash = "sha256:348e0240c33b60bbdf4e523192ef919f28cb2c3d7d5c7794f74009290f236325"}, @@ -329,7 +329,7 @@ version = "2.13.0" description = "Python style guide checker" optional = false python-versions = ">=3.9" -groups = ["main"] +groups = ["main", "dev"] files = [ {file = "pycodestyle-2.13.0-py2.py3-none-any.whl", hash = "sha256:35863c5974a271c7a726ed228a14a4f6daf49df369d8c50cd9a6f58a5e143ba9"}, {file = "pycodestyle-2.13.0.tar.gz", hash = "sha256:c8415bf09abe81d9c7f872502a6eee881fbe85d8763dd5b9924bb0a01d67efae"}, @@ -475,7 +475,7 @@ version = "3.3.2" description = "passive checker of Python programs" optional = false python-versions = ">=3.9" -groups = ["main"] +groups = ["main", "dev"] files = [ {file = "pyflakes-3.3.2-py2.py3-none-any.whl", hash = "sha256:5039c8339cbb1944045f4ee5466908906180f13cc99cc9949348d10f82a5c32a"}, {file = "pyflakes-3.3.2.tar.gz", hash = "sha256:6dfd61d87b97fba5dcfaaf781171ac16be16453be6d816147989e7f6e6a9576b"}, @@ -595,4 +595,4 @@ test = ["covdefaults (>=2.3)", "coverage (>=7.2.7)", "coverage-enable-subprocess [metadata] lock-version = "2.1" python-versions = ">=3.12, <3.14" -content-hash = "bbf3d4328e042f797630fcdc27fcd3547247c651ffab53c7747dae2c43141a32" +content-hash = "c623fd5285626d5fd4da5bc2c6f53336fc97b5d26c231551538686246ac80f3e" diff --git a/pyproject.toml b/pyproject.toml index 3166477..796b32a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -19,6 +19,7 @@ pydantic = "^2.11.3" [tool.poetry.group.dev.dependencies] pre-commit = "^4.2.0" +flake8 = "^7.2.0" [tool.black] line-length = 120 From 3ccd2c4fdb67c245dfefa43214a1ccc9f6e791b2 Mon Sep 17 00:00:00 2001 From: alex_the_nugget <2005kan@mail.ru> Date: Fri, 2 May 2025 13:57:37 +0500 Subject: [PATCH 3/3] Update README.md --- README.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index fd0a016..b0a7d74 100644 --- a/README.md +++ b/README.md @@ -35,11 +35,11 @@ poetry run python main.py help ## 🎮 Режимы игры -| Режим | Описание | -|-----------------|----------------------------------------------------| -| Игрок vs Игрок | Классический вариант для двух игроков | -| Простой ИИ | Компьютер делает случайные ходы | -| Умный ИИ | Компьютер использует стратегию для победы | +| Режим | Описание | +|:------------:|:---------------------------------------------:| +| **Игрок vs Игрок** | Классический вариант для двух игроков | +| **Простой ИИ** | Компьютер делает случайные ходы | +| **Умный ИИ** | Компьютер использует стратегию для победы | ## 🏆 Правила игры - Игроки ходят по очереди, ставя точки на пересечения линий