diff --git a/shahovaa/zadanie 2/.gitignore b/shahovaa/zadanie 2/.gitignore
new file mode 100644
index 0000000..218a8cd
--- /dev/null
+++ b/shahovaa/zadanie 2/.gitignore
@@ -0,0 +1,4 @@
+.DS_Store
+__pycache__/
+*.pyc
+.pytest_cache/
diff --git a/shahovaa/zadanie 2/README.md b/shahovaa/zadanie 2/README.md
new file mode 100644
index 0000000..0b6d252
--- /dev/null
+++ b/shahovaa/zadanie 2/README.md
@@ -0,0 +1,67 @@
+# Поиск выхода из лабиринта
+
+Объектно-ориентированная реализация поиска пути в лабиринте с паттернами GoF:
+Builder, Strategy, Observer и Command.
+
+## Что реализовано
+
+- модель `Cell` и `Maze`;
+- загрузка лабиринта из текстового файла через `TextFileMazeBuilder`;
+- стратегии поиска пути: BFS, DFS, A*, Дейкстра;
+- `MazeSolver`, который измеряет время, число посещенных клеток и длину пути;
+- консольный `Observer` для сообщений и отрисовки;
+- `MoveCommand` и `Player` для ручного режима с undo;
+- генератор тестовых лабиринтов;
+- экспериментальный скрипт, CSV и SVG-графики;
+- отчет: `reports/report.md`.
+
+## Формат лабиринта
+
+```text
+# - стена
+ - проход
+S - старт
+E - выход
+2, 3, ~ - проходимые клетки с увеличенным весом
+```
+
+Все строки в файле лабиринта должны иметь одинаковую длину.
+
+## Запуск
+
+```bash
+python3 scripts/generate_mazes.py
+python3 main.py --maze data/mazes/small.txt --strategy astar --render
+```
+
+Доступные стратегии:
+
+```text
+bfs
+dfs
+astar
+dijkstra
+```
+
+Ручной режим с командами `W/A/S/D`, undo через `Z`:
+
+```bash
+python3 main.py --maze data/mazes/small.txt --manual
+```
+
+## Эксперименты
+
+```bash
+python3 scripts/run_experiments.py
+```
+
+Скрипт перегенерирует лабиринты, запускает каждую стратегию 10 раз и сохраняет:
+
+- `reports/results.csv`;
+- SVG-графики в `reports/charts/`.
+
+## Проверка
+
+```bash
+python3 -m unittest
+```
diff --git a/shahovaa/zadanie 2/data/mazes/empty.txt b/shahovaa/zadanie 2/data/mazes/empty.txt
new file mode 100644
index 0000000..c83b6cd
--- /dev/null
+++ b/shahovaa/zadanie 2/data/mazes/empty.txt
@@ -0,0 +1,50 @@
+##################################################
+#S #
+# #
+# #
+# #
+# #
+# #
+# #
+# #
+# #
+# #
+# #
+# #
+# #
+# #
+# #
+# #
+# #
+# #
+# #
+# #
+# #
+# #
+# #
+# #
+# #
+# #
+# #
+# #
+# #
+# #
+# #
+# #
+# #
+# #
+# #
+# #
+# #
+# #
+# #
+# #
+# #
+# #
+# #
+# #
+# #
+# #
+# #
+# E#
+##################################################
diff --git a/shahovaa/zadanie 2/data/mazes/large.txt b/shahovaa/zadanie 2/data/mazes/large.txt
new file mode 100644
index 0000000..901f0a6
--- /dev/null
+++ b/shahovaa/zadanie 2/data/mazes/large.txt
@@ -0,0 +1,100 @@
+####################################################################################################
+#S # # # # # # # # # # # ##
+### # # ### # # # ####### # # # ### ######### ### # ##### # ############### ### # # # ### ##### # ##
+# # # # # # # # # # # # # # # # # # # # # # # # # ##
+# ########### # # # # ##### ### # ### ##### ################### # # # # ##### ####### # ### ##### ##
+# # # # # # # # # # # # # # # # # # # # # # # ##
+# ### ##### ### ### ##### ##### ### ### ##### ####### # # ### ##### ##### # ### # # # # # # # # # ##
+# # # # # # # # # # # # # # # # # # # # # # # # # # # # # # ##
+##### # ######### ### # ### # ### ### # # ####### # ### # # # # # ### # ##### # # # ### ##### # ####
+# # # # # # # # # # # # # # # # # # # # # # # ##
+# ##### ##### ########### # ####### ##### ######### # ### # # ##### ####### ### # # # ### # ##### ##
+# # # # # # # # # # # # # # # # # # # # # # # # ##
+# # # ##### # # # # ### # # # # ### # ##### ### ### ########### # ##### ### # ### # ### # ####### ##
+# # # # # # # # # # # # # # # # # # # # # # # # ##
+# ####### # # ####### # # ####### ####### ### ##### # ############### ### # # # # ####### # # ######
+# # # # # # # # # # # # # # # # # # # # # # # # ##
+##### # # # ##### # # ####### ########### # ### # ### # # # ####### ### # ####### # ### # # ##### ##
+# # # # # # # # # # # # # # # # # # # # # # # ##
+# # ### ##### # ############# # # # ### ### # ########### ####### # ### # ######### # ### # ### # ##
+# # # # # # # # # # # # # # # # # # # # # # # # ##
+# ### ### ####### ### # ### # # ##### ### ##### ####### # # ### ### # ######### # # ### ### # ### ##
+# # # # # # # # # # # # # # # # # # # # # # # # # # ##
+# # ####### ####### ### # ### ##### # # # # # ##### ##### ### ### # # # ### # ### ### ##### # # # ##
+# # # # # # # # # # # # # # # # # # # # # # # # # # # ##
+# ######### # # # ### ##### ### # ### ### # ##### # ####### ### ##### # # # ##### # ########### # ##
+# # # # # # # E# # # # # # # # # # # # # # # # ##
+### ##### # # ####### ### # ### ### ##### ### # # # ### # # ####### # ### ######### # ##### ##### ##
+# # # # # # # # # # # # # # # # # # # # # # # # # ##
+# ### ##### ####### # # ##### ######### # # ### ##### # ##### ### # # ####### # # ### # ####### # ##
+# # # # # # # # # # # # # # # # # # # # # # # # ##
+####### # # ### ### ### # # # # ############# ##### # # # ##### ### ### ### # # ##### # # # ##### ##
+# # # # # # # # # # # # # # # # # # # # # # # # # # ##
+# ####### # # ### ### ### ########### # # # ##### # ##### ### ### # ##### ##### # # # # # # # ######
+# # # # # # # # # # # # # # # # # # # # # # # # # # ##
+# # ##### # ### ### ########### ############# # # # # # # # ### # ### ##### # # # ##### # # ### # ##
+# # # # # # # # # # # # # # # # # # # # # # # # # # # # # # ##
+# ##### # # # ### # # ### # # # # # ####### ### # ##### # ### # ####### # # # # # ### # # ####### ##
+# # # # # # # # # # # # # # # # # # # # # # # # # # # ##
+# ##### # # # # # ##### ########### # ########### # # ##### ### ##### ### # # # ####### ######### ##
+# # # # # # # # # # # # # # # # # # # # ##
+### ##### # # ##### ##### ########### ##### ### # # ##### ### # # ######### ####### # # ######### ##
+# # # # # # # # # # # # # # # # # # # # # ##
+# ##### ### ######### # ### ##### ### # # # ####### ####### # ####### ### # # ####### # # ##### # ##
+# # # # # # # # # # # # # # # # # # # # # # # # # # # # ##
+# # ### ### # ##### # # ### # # ### ### # # # ####### ### # # ##### # ### ### # # ### ### # # ### ##
+# # # # # # # # # # # # # # # # # # # # # # # # # # # ##
+# ### # # ####### # ### # ### # ############### # ##### # # ##### # ### # # ######### ##### ### # ##
+# # # # # # # # # # # # # # # # # # # # # # # # # # # ##
+### # ### # # # # ### ##### # ####### ### # ######### # # # # # ##### # # ######### # # ####### # ##
+# # # # # # # # # # # # # # # # # # # # # # ##
+# ### ####### ########### ######### ##### # ### # # ### ####### # ##### ####### # ### ### # ##### ##
+# # # # # # # # # # # # # # # # # # # # # # # # # # ##
+# ######### # # ##### # ### ##### ### # ##### # # # # ### ### ### # ### # ### ### # # # ### # # ####
+# # # # # # # # # # # # # # # # # # # # # # # # # # ##
+# ### # # # ### # ##### # ##### ######### # # # ### ### # # ### ### # ##### ########### # ### ### ##
+# # # # # # # # # # # # # # # # # # # # # # # # # # # ##
+### # # # ### ##### # # # ### ### # # # # ### ############# ### # # ### ########### ##### # ##### ##
+# # # # # # # # # # # # # # # # # # # # # # ##
+# ### ############### # ### # # ##### ##### ### ######### ############# # ####### ##### # # # ######
+# # # # # # # # # # # # # # # # # # # ##
+### ############# # # # # ####### ##### # ####### ######### # ### ######### # # ##### ### # # ### ##
+# # # # # # # # # # # # # # # # # # # # # # # # # # # ##
+# # ### # # # # # ######### ####### ### ### # # ### # ### ### # ### ### # ### ##### # # ### ### # ##
+# # # # # # # # # # # # # # # # # # # # # # # # # ##
+# ### ### # # ##### # ############### ### ##### # ### ####### # # ### # # ### # # ##### # # # # # ##
+# # # # # # # # # # # # # # # # # # # # # # # # # # # # ##
+### # # # # # # ##### # ####### # ##### # ### ##### ### # # ##### # # ##### # ##### # # # ####### ##
+# # # # # # # # # # # # # # # # # # # # # # # # # # # ##
+# # # # ################### ########### # # ### # ### ##### # # # # ########### ##### ### # # # # ##
+# # # # # # # # # # # # # # # # # # # # ##
+# ### ##### ### ######### ########### ### # ########### ### # # ### # # # # # ######### # ### ######
+# # # # # # # # # # # # # # # # # # # # # # # ##
+### # # ##### ### ### # # # # ####### # ##### # ##### # # ### ### ######### ####### # # ##### # # ##
+# # # # # # # # # # # # # # # # # # # # # # # # # # # # # ##
+# ### # # # ### # ### # # ##### ### ##### # ##### # ### # # # # ### # # ### # ############# ##### ##
+# # # # # # # # # # # # # # # # # # # # # # # # # # # ##
+### # ### # # # # # ####### # # # ### ####### # ######### # ### # ### # # ### ##### # # ####### # ##
+# # # # # # # # # # # # # # # # # # # # # # # # # # ##
+# ### # # ######### # ####### # ######### ### # # # ### ### # ##### ### # ##### # ##### # ### ### ##
+# # # # # # # # # # # # # # # # # # # # # # # # # # # # ##
+# # ### # # ##### # ##### # # # ####### ### # ### ### ##### # # # ### # ### # ### # # ######### # ##
+# # # # # # # # # # # # # # # # # # # # # # # # # # ##
+# ### ### ### ####### ### # # ### ####### # ### ### ##### # # # ### ### # ### # ######### # ##### ##
+# # # # # # # # # # # # # # # # # # # # # # # # # ##
+# ####### ##### # ### # ### ####### ################# ### # # # ##### ### # # # # ### ####### ######
+# # # # # # # # # # # # # # # # # # # # # ##
+##### ##### ##### ##### # ####### # ##### # ### ##### # ### # # ### ########### # # # # # # ### # ##
+# # # # # # # # # # # # # # # # # # # # # # # # # # # ##
+# # # # ####### # # # ####### # # # ##### ### ##### ##### ### ########### ### # # # ##### # # ### ##
+# # # # # # # # # # # # # # # # # # # # # # # # ##
+# # # # # # # ##### ##### # ### # # # ########### ######### ### ########### ### ##### # ### # ######
+# # # # # # # # # # # # # # # # # # # # # ##
+### ##### ##### ##### # # # ##### ##### # ################# # ### # ### ######### # # ### # # ### ##
+# # # # # # # # # # # # # # # # # # # # # # # # # ##
+# ### # ##### ### ### # # # # # ### ############# ##### # ##### ##### # # ### # ### # # # # ### # ##
+# # # # # # # # # # # # # # # # # # # # # # # # # # # # # ##
+# ####### # ##### # ### # ### # ##### ####### # ### # ####### ### ##### # # # # # ##### # # # ### ##
+# # # # # # # # # # # # # ##
+####################################################################################################
+####################################################################################################
diff --git a/shahovaa/zadanie 2/data/mazes/medium.txt b/shahovaa/zadanie 2/data/mazes/medium.txt
new file mode 100644
index 0000000..33b95a7
--- /dev/null
+++ b/shahovaa/zadanie 2/data/mazes/medium.txt
@@ -0,0 +1,50 @@
+##################################################
+#S # # E# # # ##
+### # ##### # # ########### ### # # # ### ##### ##
+# # # # # # # # # # # # ##
+# ### ### # ### ############### ### # # ### # # ##
+# # # # # # # # # # # ##
+### ### ##### # ##### ### # # # # ##### # ##### ##
+# # # # # # # # # # # # # # # # ##
+# ### ### # # ### # ### ### # ### # # ### # ######
+# # # # # # # # # # # # # ##
+# # ##### ##### # # # # ####### ### ########### ##
+# # # # # # # # # # # # ##
+####### # # ##### # # # # ### ### # # ### ########
+# # # # # # # # # # # # ##
+# ### ##### ##### # # ##### # # ############### ##
+# # # # # # # # # # # # # ##
+# # ### # ### # ### ### # ### ### # # ##### # ####
+# # # # # # # # # # # # # # ##
+# ### ########### ### # ### ### # # ### # # ### ##
+# # # # # # # # # # # # ##
+# # ### # ##### # # ######### # # # # # ####### ##
+# # # # # # # # # # # # # ##
+# ######### # # # ### ##### # # ##### ### ##### ##
+# # # # # # # # # # # # ##
+# ##### # # # ### ##### # ######### ### ##### # ##
+# # # # # # # # # # # # # ##
+# # ##### # ### # # # ########### ### # # ### # ##
+# # # # # # # # # # # # # # # ##
+# ### # ### # ##### # # ### # ######### # # ######
+# # # # # # # # # # # ##
+### # # # ########### ### ############### ##### ##
+# # # # # # # # # # # ##
+# ##### ##### # ### ### # # ### # ### # # # # # ##
+# # # # # # # # # # # # # # ##
+# ####### # # # # ####### ### ##### ### ##### # ##
+# # # # # # # # # # # # # # ##
+# ##### ### # ### # ### # # # # # ##### # # ### ##
+# # # # # # # # # # # # ##
+######### # ######### ####### ########### # # # ##
+# # # # # # # # ##
+# ####### ##### # # ####### ######### # ####### ##
+# # # # # # # # # # # # ##
+### # # ### # # # ##### # # # ##### ### # # ######
+# # # # # # # # # # # # # ##
+# ############# # # # ##### ######### ### # # # ##
+# # # # # # # # # # # # # # # ##
+# ##### # # # ##### # # # ### # # # ### # # # # ##
+# # # # # # # # # ##
+##################################################
+##################################################
diff --git a/shahovaa/zadanie 2/data/mazes/no_exit.txt b/shahovaa/zadanie 2/data/mazes/no_exit.txt
new file mode 100644
index 0000000..28c6043
--- /dev/null
+++ b/shahovaa/zadanie 2/data/mazes/no_exit.txt
@@ -0,0 +1,30 @@
+##############################
+#S############################
+##############################
+##############################
+##############################
+##############################
+##############################
+##############################
+##############################
+##############################
+##############################
+##############################
+##############################
+##############################
+##############################
+##############################
+##############################
+##############################
+##############################
+##############################
+##############################
+##############################
+##############################
+##############################
+##############################
+##############################
+##############################
+##############################
+############################E#
+##############################
diff --git a/shahovaa/zadanie 2/data/mazes/small.txt b/shahovaa/zadanie 2/data/mazes/small.txt
new file mode 100644
index 0000000..f14cd33
--- /dev/null
+++ b/shahovaa/zadanie 2/data/mazes/small.txt
@@ -0,0 +1,10 @@
+##########
+#S #E#
+# #### # #
+# # # #
+# # #### #
+# # #
+# ###### #
+# #
+######## #
+##########
diff --git a/shahovaa/zadanie 2/main.py b/shahovaa/zadanie 2/main.py
new file mode 100644
index 0000000..62f5f6b
--- /dev/null
+++ b/shahovaa/zadanie 2/main.py
@@ -0,0 +1,94 @@
+from __future__ import annotations
+
+import argparse
+
+from maze_solver import (
+ AStarStrategy,
+ BFSStrategy,
+ ConsoleView,
+ DFSStrategy,
+ DijkstraStrategy,
+ Direction,
+ MazeSolver,
+ MoveCommand,
+ Player,
+ TextFileMazeBuilder,
+)
+
+
+STRATEGIES = {
+ "bfs": BFSStrategy,
+ "dfs": DFSStrategy,
+ "astar": AStarStrategy,
+ "dijkstra": DijkstraStrategy,
+}
+
+
+def main() -> None:
+ parser = argparse.ArgumentParser(description="Find a path through a text maze.")
+ parser.add_argument("--maze", default="data/mazes/small.txt")
+ parser.add_argument(
+ "--strategy",
+ choices=sorted(STRATEGIES),
+ default="astar",
+ help="Path-finding algorithm.",
+ )
+ parser.add_argument("--render", action="store_true", help="Print maze with path.")
+ parser.add_argument(
+ "--manual",
+ action="store_true",
+ help="Manual W/A/S/D mode with Z undo and Q quit.",
+ )
+ args = parser.parse_args()
+
+ maze = TextFileMazeBuilder().build_from_file(args.maze)
+ strategy = STRATEGIES[args.strategy]()
+ solver = MazeSolver(maze, strategy)
+ view = ConsoleView()
+ solver.add_observer(view)
+
+ stats = solver.solve()
+ print(
+ f"Summary: strategy={stats.strategy_name}, time={stats.time_ms:.3f} ms, "
+ f"visited={stats.visited_cells}, path_length={stats.path_length}"
+ )
+
+ if args.render:
+ print(view.render(maze, path=stats.path))
+
+ if args.manual:
+ run_manual_mode(maze, view)
+
+
+def run_manual_mode(maze, view: ConsoleView) -> None:
+ player = Player.at_start(maze)
+ history: list[MoveCommand] = []
+
+ while True:
+ print(view.render(maze, player_position=player.current_cell))
+ if player.current_cell == maze.exit:
+ print("Exit reached.")
+ return
+
+ key = input("Move W/A/S/D, undo Z, quit Q: ").strip().lower()
+ if key == "q":
+ return
+ if key == "z":
+ if history:
+ history.pop().undo()
+ continue
+
+ try:
+ command = MoveCommand(player, Direction.from_key(key))
+ except ValueError as exc:
+ print(exc)
+ continue
+
+ if command.execute():
+ history.append(command)
+ else:
+ print("Move blocked.")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/shahovaa/zadanie 2/maze_solver/__init__.py b/shahovaa/zadanie 2/maze_solver/__init__.py
new file mode 100644
index 0000000..ce72ea5
--- /dev/null
+++ b/shahovaa/zadanie 2/maze_solver/__init__.py
@@ -0,0 +1,34 @@
+from .builders import MazeBuilder, TextFileMazeBuilder
+from .commands import Direction, MoveCommand, Player
+from .models import Cell, Maze
+from .observers import ConsoleView, Event, Observer
+from .solver import MazeSolver, SearchStats
+from .strategies import (
+ AStarStrategy,
+ BFSStrategy,
+ DFSStrategy,
+ DijkstraStrategy,
+ PathFindingStrategy,
+ PathResult,
+)
+
+__all__ = [
+ "AStarStrategy",
+ "BFSStrategy",
+ "Cell",
+ "ConsoleView",
+ "DFSStrategy",
+ "DijkstraStrategy",
+ "Direction",
+ "Event",
+ "Maze",
+ "MazeBuilder",
+ "MazeSolver",
+ "MoveCommand",
+ "Observer",
+ "PathFindingStrategy",
+ "PathResult",
+ "Player",
+ "SearchStats",
+ "TextFileMazeBuilder",
+]
diff --git a/shahovaa/zadanie 2/maze_solver/builders.py b/shahovaa/zadanie 2/maze_solver/builders.py
new file mode 100644
index 0000000..1316ae3
--- /dev/null
+++ b/shahovaa/zadanie 2/maze_solver/builders.py
@@ -0,0 +1,75 @@
+from __future__ import annotations
+
+from abc import ABC, abstractmethod
+from pathlib import Path
+
+from .models import Cell, Maze
+
+
+class MazeBuilder(ABC):
+ @abstractmethod
+ def build_from_file(self, filename: str | Path) -> Maze:
+ raise NotImplementedError
+
+ def buildFromFile(self, filename: str | Path) -> Maze:
+ return self.build_from_file(filename)
+
+
+class TextFileMazeBuilder(MazeBuilder):
+ WALL = "#"
+ START = "S"
+ EXIT = "E"
+ PASSAGES = {" ", "."}
+ WEIGHTS = {"1": 1, "2": 2, "3": 3, "~": 3}
+
+ def build_from_file(self, filename: str | Path) -> Maze:
+ path = Path(filename)
+ rows = path.read_text(encoding="utf-8").splitlines()
+
+ if not rows:
+ raise ValueError(f"Maze file is empty: {path}")
+
+ width = len(rows[0])
+ if width == 0:
+ raise ValueError("Maze width must be greater than zero")
+ if any(len(row) != width for row in rows):
+ raise ValueError("All maze rows must have the same width")
+
+ cells: list[list[Cell]] = []
+ start: Cell | None = None
+ exit: Cell | None = None
+
+ for y, row in enumerate(rows):
+ cell_row: list[Cell] = []
+ for x, char in enumerate(row):
+ cell = self._create_cell(x, y, char)
+ if cell.is_start:
+ if start is not None:
+ raise ValueError("Maze must contain exactly one start cell")
+ start = cell
+ if cell.is_exit:
+ if exit is not None:
+ raise ValueError("Maze must contain exactly one exit cell")
+ exit = cell
+ cell_row.append(cell)
+ cells.append(cell_row)
+
+ if start is None:
+ raise ValueError("Maze must contain a start cell marked with 'S'")
+ if exit is None:
+ raise ValueError("Maze must contain an exit cell marked with 'E'")
+
+ return Maze(cells, start, exit)
+
+ def _create_cell(self, x: int, y: int, char: str) -> Cell:
+ if char == self.WALL:
+ return Cell(x=x, y=y, is_wall=True, symbol=char)
+ if char == self.START:
+ return Cell(x=x, y=y, is_start=True, symbol=char)
+ if char == self.EXIT:
+ return Cell(x=x, y=y, is_exit=True, symbol=char)
+ if char in self.PASSAGES:
+ return Cell(x=x, y=y, symbol=" ")
+ if char in self.WEIGHTS:
+ return Cell(x=x, y=y, weight=self.WEIGHTS[char], symbol=char)
+ raise ValueError(f"Unsupported maze symbol {char!r} at ({x}, {y})")
diff --git a/shahovaa/zadanie 2/maze_solver/commands.py b/shahovaa/zadanie 2/maze_solver/commands.py
new file mode 100644
index 0000000..e93341c
--- /dev/null
+++ b/shahovaa/zadanie 2/maze_solver/commands.py
@@ -0,0 +1,79 @@
+from __future__ import annotations
+
+from abc import ABC, abstractmethod
+from dataclasses import dataclass
+from enum import Enum
+
+from .models import Cell, Maze
+
+
+class Direction(Enum):
+ UP = (0, -1)
+ RIGHT = (1, 0)
+ DOWN = (0, 1)
+ LEFT = (-1, 0)
+
+ @classmethod
+ def from_key(cls, key: str) -> "Direction":
+ mapping = {
+ "w": cls.UP,
+ "d": cls.RIGHT,
+ "s": cls.DOWN,
+ "a": cls.LEFT,
+ }
+ try:
+ return mapping[key.lower()]
+ except KeyError as exc:
+ raise ValueError("Use W/A/S/D for movement") from exc
+
+
+@dataclass
+class Player:
+ maze: Maze
+ current_cell: Cell
+
+ @classmethod
+ def at_start(cls, maze: Maze) -> "Player":
+ return cls(maze=maze, current_cell=maze.start)
+
+ def move_to(self, cell: Cell) -> None:
+ if not cell.is_passable():
+ raise ValueError("Player cannot move into a wall")
+ self.current_cell = cell
+
+
+class Command(ABC):
+ @abstractmethod
+ def execute(self) -> bool:
+ raise NotImplementedError
+
+ @abstractmethod
+ def undo(self) -> bool:
+ raise NotImplementedError
+
+
+class MoveCommand(Command):
+ def __init__(self, player: Player, direction: Direction) -> None:
+ self.player = player
+ self.direction = direction
+ self.previous_cell: Cell | None = None
+ self.executed = False
+
+ def execute(self) -> bool:
+ dx, dy = self.direction.value
+ current = self.player.current_cell
+ target = self.player.maze.get_cell(current.x + dx, current.y + dy)
+ if target is None or not target.is_passable():
+ return False
+
+ self.previous_cell = current
+ self.player.move_to(target)
+ self.executed = True
+ return True
+
+ def undo(self) -> bool:
+ if not self.executed or self.previous_cell is None:
+ return False
+ self.player.move_to(self.previous_cell)
+ self.executed = False
+ return True
diff --git a/shahovaa/zadanie 2/maze_solver/models.py b/shahovaa/zadanie 2/maze_solver/models.py
new file mode 100644
index 0000000..e6523a1
--- /dev/null
+++ b/shahovaa/zadanie 2/maze_solver/models.py
@@ -0,0 +1,81 @@
+from __future__ import annotations
+
+from dataclasses import dataclass
+
+
+@dataclass(frozen=True)
+class Cell:
+ x: int
+ y: int
+ is_wall: bool = False
+ is_start: bool = False
+ is_exit: bool = False
+ weight: int = 1
+ symbol: str = " "
+
+ def is_passable(self) -> bool:
+ return not self.is_wall
+
+ def isPassable(self) -> bool:
+ return self.is_passable()
+
+
+class Maze:
+ def __init__(self, cells: list[list[Cell]], start: Cell, exit: Cell) -> None:
+ if not cells or not cells[0]:
+ raise ValueError("Maze must contain at least one cell")
+
+ width = len(cells[0])
+ if any(len(row) != width for row in cells):
+ raise ValueError("Maze rows must have equal width")
+
+ self.cells = cells
+ self.height = len(cells)
+ self.width = width
+ self.start = start
+ self.exit = exit
+
+ def get_cell(self, x: int, y: int) -> Cell | None:
+ if 0 <= x < self.width and 0 <= y < self.height:
+ return self.cells[y][x]
+ return None
+
+ def getCell(self, x: int, y: int) -> Cell | None:
+ return self.get_cell(x, y)
+
+ def get_neighbors(self, cell: Cell) -> list[Cell]:
+ neighbors: list[Cell] = []
+ for dx, dy in ((0, -1), (1, 0), (0, 1), (-1, 0)):
+ neighbor = self.get_cell(cell.x + dx, cell.y + dy)
+ if neighbor is not None and neighbor.is_passable():
+ neighbors.append(neighbor)
+ return neighbors
+
+ def getNeighbors(self, cell: Cell) -> list[Cell]:
+ return self.get_neighbors(cell)
+
+ def to_text(self, path: list[Cell] | None = None, player: Cell | None = None) -> str:
+ path_cells = {(cell.x, cell.y) for cell in path or []}
+ lines: list[str] = []
+
+ for row in self.cells:
+ chars: list[str] = []
+ for cell in row:
+ position = (cell.x, cell.y)
+ if player is not None and position == (player.x, player.y):
+ chars.append("@")
+ elif cell.is_start:
+ chars.append("S")
+ elif cell.is_exit:
+ chars.append("E")
+ elif cell.is_wall:
+ chars.append("#")
+ elif position in path_cells:
+ chars.append(".")
+ elif cell.weight > 1:
+ chars.append(str(cell.weight))
+ else:
+ chars.append(" ")
+ lines.append("".join(chars))
+
+ return "\n".join(lines)
diff --git a/shahovaa/zadanie 2/maze_solver/observers.py b/shahovaa/zadanie 2/maze_solver/observers.py
new file mode 100644
index 0000000..3fecea2
--- /dev/null
+++ b/shahovaa/zadanie 2/maze_solver/observers.py
@@ -0,0 +1,40 @@
+from __future__ import annotations
+
+from abc import ABC, abstractmethod
+from dataclasses import dataclass, field
+from typing import Any
+
+from .models import Cell, Maze
+
+
+@dataclass(frozen=True)
+class Event:
+ event_type: str
+ payload: dict[str, Any] = field(default_factory=dict)
+
+
+class Observer(ABC):
+ @abstractmethod
+ def update(self, event: Event) -> None:
+ raise NotImplementedError
+
+
+class ConsoleView(Observer):
+ def update(self, event: Event) -> None:
+ if event.event_type == "search_started":
+ print(f"Search started: {event.payload['strategy']}")
+ elif event.event_type in {"path_found", "path_not_found"}:
+ stats = event.payload["stats"]
+ print(
+ f"{event.event_type}: strategy={stats.strategy_name}, "
+ f"time={stats.time_ms:.3f} ms, visited={stats.visited_cells}, "
+ f"path_length={stats.path_length}"
+ )
+
+ def render(
+ self,
+ maze: Maze,
+ player_position: Cell | None = None,
+ path: list[Cell] | None = None,
+ ) -> str:
+ return maze.to_text(path=path, player=player_position)
diff --git a/shahovaa/zadanie 2/maze_solver/solver.py b/shahovaa/zadanie 2/maze_solver/solver.py
new file mode 100644
index 0000000..e95d6e9
--- /dev/null
+++ b/shahovaa/zadanie 2/maze_solver/solver.py
@@ -0,0 +1,57 @@
+from __future__ import annotations
+
+import time
+from dataclasses import dataclass
+
+from .models import Cell, Maze
+from .observers import Event, Observer
+from .strategies import PathFindingStrategy
+
+
+@dataclass(frozen=True)
+class SearchStats:
+ strategy_name: str
+ time_ms: float
+ visited_cells: int
+ path_length: int
+ path: list[Cell]
+
+
+class MazeSolver:
+ def __init__(self, maze: Maze, strategy: PathFindingStrategy) -> None:
+ self.maze = maze
+ self.strategy = strategy
+ self._observers: list[Observer] = []
+
+ def set_strategy(self, strategy: PathFindingStrategy) -> None:
+ self.strategy = strategy
+
+ def setStrategy(self, strategy: PathFindingStrategy) -> None:
+ self.set_strategy(strategy)
+
+ def add_observer(self, observer: Observer) -> None:
+ self._observers.append(observer)
+
+ def remove_observer(self, observer: Observer) -> None:
+ self._observers.remove(observer)
+
+ def solve(self) -> SearchStats:
+ self._notify(Event("search_started", {"strategy": self.strategy.name}))
+ started_at = time.perf_counter()
+ result = self.strategy.find_path(self.maze, self.maze.start, self.maze.exit)
+ elapsed_ms = (time.perf_counter() - started_at) * 1000
+
+ stats = SearchStats(
+ strategy_name=self.strategy.name,
+ time_ms=elapsed_ms,
+ visited_cells=result.visited_count,
+ path_length=len(result.path),
+ path=result.path,
+ )
+ event_name = "path_found" if result.path else "path_not_found"
+ self._notify(Event(event_name, {"stats": stats}))
+ return stats
+
+ def _notify(self, event: Event) -> None:
+ for observer in self._observers:
+ observer.update(event)
diff --git a/shahovaa/zadanie 2/maze_solver/strategies.py b/shahovaa/zadanie 2/maze_solver/strategies.py
new file mode 100644
index 0000000..739b7ec
--- /dev/null
+++ b/shahovaa/zadanie 2/maze_solver/strategies.py
@@ -0,0 +1,150 @@
+from __future__ import annotations
+
+import heapq
+from abc import ABC, abstractmethod
+from collections import deque
+from dataclasses import dataclass
+from itertools import count
+
+from .models import Cell, Maze
+
+
+@dataclass(frozen=True)
+class PathResult:
+ path: list[Cell]
+ visited_count: int
+
+
+class PathFindingStrategy(ABC):
+ name = "abstract"
+
+ @abstractmethod
+ def find_path(self, maze: Maze, start: Cell, exit: Cell) -> PathResult:
+ raise NotImplementedError
+
+ def findPath(self, maze: Maze, start: Cell, exit: Cell) -> PathResult:
+ return self.find_path(maze, start, exit)
+
+
+class BFSStrategy(PathFindingStrategy):
+ name = "BFS"
+
+ def find_path(self, maze: Maze, start: Cell, exit: Cell) -> PathResult:
+ queue: deque[Cell] = deque([start])
+ parents: dict[Cell, Cell | None] = {start: None}
+ visited = {start}
+
+ while queue:
+ current = queue.popleft()
+ if current == exit:
+ return PathResult(_reconstruct_path(parents, exit), len(visited))
+
+ for neighbor in maze.get_neighbors(current):
+ if neighbor not in visited:
+ visited.add(neighbor)
+ parents[neighbor] = current
+ queue.append(neighbor)
+
+ return PathResult([], len(visited))
+
+
+class DFSStrategy(PathFindingStrategy):
+ name = "DFS"
+
+ def find_path(self, maze: Maze, start: Cell, exit: Cell) -> PathResult:
+ stack = [start]
+ parents: dict[Cell, Cell | None] = {start: None}
+ visited = {start}
+
+ while stack:
+ current = stack.pop()
+ if current == exit:
+ return PathResult(_reconstruct_path(parents, exit), len(visited))
+
+ for neighbor in reversed(maze.get_neighbors(current)):
+ if neighbor not in visited:
+ visited.add(neighbor)
+ parents[neighbor] = current
+ stack.append(neighbor)
+
+ return PathResult([], len(visited))
+
+
+class DijkstraStrategy(PathFindingStrategy):
+ name = "Dijkstra"
+
+ def find_path(self, maze: Maze, start: Cell, exit: Cell) -> PathResult:
+ tie_breaker = count()
+ heap: list[tuple[int, int, Cell]] = [(0, next(tie_breaker), start)]
+ distances: dict[Cell, int] = {start: 0}
+ parents: dict[Cell, Cell | None] = {start: None}
+ visited: set[Cell] = set()
+
+ while heap:
+ current_distance, _, current = heapq.heappop(heap)
+ if current in visited:
+ continue
+ visited.add(current)
+
+ if current == exit:
+ return PathResult(_reconstruct_path(parents, exit), len(visited))
+
+ for neighbor in maze.get_neighbors(current):
+ new_distance = current_distance + neighbor.weight
+ if new_distance < distances.get(neighbor, 10**12):
+ distances[neighbor] = new_distance
+ parents[neighbor] = current
+ heapq.heappush(heap, (new_distance, next(tie_breaker), neighbor))
+
+ return PathResult([], len(visited))
+
+
+class AStarStrategy(PathFindingStrategy):
+ name = "A*"
+
+ def find_path(self, maze: Maze, start: Cell, exit: Cell) -> PathResult:
+ tie_breaker = count()
+ start_heuristic = _manhattan(start, exit)
+ heap: list[tuple[int, int, int, Cell]] = [
+ (start_heuristic, start_heuristic, next(tie_breaker), start)
+ ]
+ g_score: dict[Cell, int] = {start: 0}
+ parents: dict[Cell, Cell | None] = {start: None}
+ visited: set[Cell] = set()
+
+ while heap:
+ _, _, _, current = heapq.heappop(heap)
+ if current in visited:
+ continue
+ visited.add(current)
+
+ if current == exit:
+ return PathResult(_reconstruct_path(parents, exit), len(visited))
+
+ for neighbor in maze.get_neighbors(current):
+ tentative_score = g_score[current] + neighbor.weight
+ if tentative_score < g_score.get(neighbor, 10**12):
+ g_score[neighbor] = tentative_score
+ parents[neighbor] = current
+ heuristic = _manhattan(neighbor, exit)
+ priority = tentative_score + heuristic
+ heapq.heappush(
+ heap,
+ (priority, heuristic, next(tie_breaker), neighbor),
+ )
+
+ return PathResult([], len(visited))
+
+
+def _reconstruct_path(parents: dict[Cell, Cell | None], end: Cell) -> list[Cell]:
+ path: list[Cell] = []
+ current: Cell | None = end
+ while current is not None:
+ path.append(current)
+ current = parents[current]
+ path.reverse()
+ return path
+
+
+def _manhattan(first: Cell, second: Cell) -> int:
+ return abs(first.x - second.x) + abs(first.y - second.y)
diff --git a/shahovaa/zadanie 2/reports/charts/empty_time.svg b/shahovaa/zadanie 2/reports/charts/empty_time.svg
new file mode 100644
index 0000000..8a21a08
--- /dev/null
+++ b/shahovaa/zadanie 2/reports/charts/empty_time.svg
@@ -0,0 +1,28 @@
+
\ No newline at end of file
diff --git a/shahovaa/zadanie 2/reports/charts/empty_visited.svg b/shahovaa/zadanie 2/reports/charts/empty_visited.svg
new file mode 100644
index 0000000..0133eaf
--- /dev/null
+++ b/shahovaa/zadanie 2/reports/charts/empty_visited.svg
@@ -0,0 +1,28 @@
+
\ No newline at end of file
diff --git a/shahovaa/zadanie 2/reports/charts/large_time.svg b/shahovaa/zadanie 2/reports/charts/large_time.svg
new file mode 100644
index 0000000..775b0b0
--- /dev/null
+++ b/shahovaa/zadanie 2/reports/charts/large_time.svg
@@ -0,0 +1,28 @@
+
\ No newline at end of file
diff --git a/shahovaa/zadanie 2/reports/charts/large_visited.svg b/shahovaa/zadanie 2/reports/charts/large_visited.svg
new file mode 100644
index 0000000..08114dd
--- /dev/null
+++ b/shahovaa/zadanie 2/reports/charts/large_visited.svg
@@ -0,0 +1,28 @@
+
\ No newline at end of file
diff --git a/shahovaa/zadanie 2/reports/charts/medium_time.svg b/shahovaa/zadanie 2/reports/charts/medium_time.svg
new file mode 100644
index 0000000..2e4caff
--- /dev/null
+++ b/shahovaa/zadanie 2/reports/charts/medium_time.svg
@@ -0,0 +1,28 @@
+
\ No newline at end of file
diff --git a/shahovaa/zadanie 2/reports/charts/medium_visited.svg b/shahovaa/zadanie 2/reports/charts/medium_visited.svg
new file mode 100644
index 0000000..6dd8bd2
--- /dev/null
+++ b/shahovaa/zadanie 2/reports/charts/medium_visited.svg
@@ -0,0 +1,28 @@
+
\ No newline at end of file
diff --git a/shahovaa/zadanie 2/reports/charts/no_exit_time.svg b/shahovaa/zadanie 2/reports/charts/no_exit_time.svg
new file mode 100644
index 0000000..8cf0299
--- /dev/null
+++ b/shahovaa/zadanie 2/reports/charts/no_exit_time.svg
@@ -0,0 +1,28 @@
+
\ No newline at end of file
diff --git a/shahovaa/zadanie 2/reports/charts/no_exit_visited.svg b/shahovaa/zadanie 2/reports/charts/no_exit_visited.svg
new file mode 100644
index 0000000..2e3b4a6
--- /dev/null
+++ b/shahovaa/zadanie 2/reports/charts/no_exit_visited.svg
@@ -0,0 +1,28 @@
+
\ No newline at end of file
diff --git a/shahovaa/zadanie 2/reports/charts/small_time.svg b/shahovaa/zadanie 2/reports/charts/small_time.svg
new file mode 100644
index 0000000..57896e1
--- /dev/null
+++ b/shahovaa/zadanie 2/reports/charts/small_time.svg
@@ -0,0 +1,28 @@
+
\ No newline at end of file
diff --git a/shahovaa/zadanie 2/reports/charts/small_visited.svg b/shahovaa/zadanie 2/reports/charts/small_visited.svg
new file mode 100644
index 0000000..cadf1bc
--- /dev/null
+++ b/shahovaa/zadanie 2/reports/charts/small_visited.svg
@@ -0,0 +1,28 @@
+
\ No newline at end of file
diff --git a/shahovaa/zadanie 2/reports/report.md b/shahovaa/zadanie 2/reports/report.md
new file mode 100644
index 0000000..a2f2fe9
--- /dev/null
+++ b/shahovaa/zadanie 2/reports/report.md
@@ -0,0 +1,208 @@
+# Отчет по заданию: поиск выхода из лабиринта
+
+## 1. Описание задачи и выбранных паттернов
+
+Цель работы - реализовать расширяемую программу для загрузки лабиринта из файла,
+поиска пути от старта `S` до выхода `E`, визуализации результата и сравнения
+алгоритмов на лабиринтах разной сложности.
+
+В проекте реализованы четыре паттерна GoF:
+
+| Паттерн | Где реализован | Зачем нужен |
+|---|---|---|
+| Builder | `MazeBuilder`, `TextFileMazeBuilder` | Изолирует парсинг и валидацию файла от остального приложения. |
+| Strategy | `PathFindingStrategy`, `BFSStrategy`, `DFSStrategy`, `AStarStrategy`, `DijkstraStrategy` | Позволяет менять алгоритм поиска без изменения `MazeSolver`. |
+| Observer | `Observer`, `ConsoleView`, события `search_started`, `path_found`, `path_not_found` | Отделяет вычисления от отображения в консоли. |
+| Command | `Command`, `MoveCommand`, `Player` | Инкапсулирует ход игрока и поддерживает отмену хода. |
+
+Диаграмма классов:
+
+```mermaid
+classDiagram
+ class Cell {
+ +int x
+ +int y
+ +bool is_wall
+ +bool is_start
+ +bool is_exit
+ +int weight
+ +is_passable() bool
+ }
+
+ class Maze {
+ +list cells
+ +int width
+ +int height
+ +Cell start
+ +Cell exit
+ +get_cell(x, y) Cell
+ +get_neighbors(cell) list
+ +to_text(path, player) str
+ }
+
+ class MazeBuilder {
+ <>
+ +build_from_file(filename) Maze
+ }
+
+ class TextFileMazeBuilder {
+ +build_from_file(filename) Maze
+ }
+
+ class PathFindingStrategy {
+ <>
+ +find_path(maze, start, exit) PathResult
+ }
+
+ class BFSStrategy
+ class DFSStrategy
+ class AStarStrategy
+ class DijkstraStrategy
+
+ class SearchStats {
+ +str strategy_name
+ +float time_ms
+ +int visited_cells
+ +int path_length
+ +list path
+ }
+
+ class MazeSolver {
+ +set_strategy(strategy)
+ +add_observer(observer)
+ +solve() SearchStats
+ }
+
+ class Observer {
+ <>
+ +update(event)
+ }
+
+ class ConsoleView {
+ +update(event)
+ +render(maze, player_position, path) str
+ }
+
+ class Command {
+ <>
+ +execute() bool
+ +undo() bool
+ }
+
+ class MoveCommand
+ class Player
+
+ MazeBuilder <|.. TextFileMazeBuilder
+ MazeBuilder --> Maze : creates
+ PathFindingStrategy <|.. BFSStrategy
+ PathFindingStrategy <|.. DFSStrategy
+ PathFindingStrategy <|.. AStarStrategy
+ PathFindingStrategy <|.. DijkstraStrategy
+ MazeSolver --> PathFindingStrategy : uses
+ MazeSolver --> Maze : uses
+ MazeSolver --> Observer : notifies
+ Observer <|.. ConsoleView
+ Command <|.. MoveCommand
+ MoveCommand --> Player
+ Player --> Cell
+```
+
+## 2. Ключевые классы
+
+Основные файлы проекта:
+
+| Файл | Назначение |
+|---|---|
+| `maze_solver/models.py` | Классы `Cell` и `Maze`, поиск соседей, текстовая отрисовка. |
+| `maze_solver/builders.py` | Интерфейс Builder и загрузка лабиринта из `.txt`. |
+| `maze_solver/strategies.py` | BFS, DFS, A* и Дейкстра. |
+| `maze_solver/solver.py` | Оркестратор поиска и сбор статистики. |
+| `maze_solver/observers.py` | Observer и консольное представление. |
+| `maze_solver/commands.py` | Command, игрок и undo перемещения. |
+| `main.py` | CLI для запуска поиска и ручного режима. |
+| `scripts/run_experiments.py` | Замеры и построение SVG-графиков. |
+
+Пример запуска:
+
+```bash
+python3 main.py --maze data/mazes/small.txt --strategy astar --render
+```
+
+## 3. Результаты экспериментов
+
+Для каждого лабиринта и каждой стратегии выполнено 10 запусков. В таблице указаны
+средние значения. Длина пути считается в клетках, включая старт и выход.
+
+| Лабиринт | Стратегия | Время, мс | Посещено клеток | Длина пути | Путь найден |
+|---|---:|---:|---:|---:|---|
+| Маленький 10x10 | BFS | 0.0423 | 37.0 | 20.0 | да |
+| Маленький 10x10 | DFS | 0.0273 | 24.0 | 22.0 | да |
+| Маленький 10x10 | A* | 0.0677 | 31.0 | 20.0 | да |
+| Маленький 10x10 | Dijkstra | 0.0551 | 37.0 | 20.0 | да |
+| Средний 50x50 | BFS | 1.2769 | 1151.0 | 709.0 | да |
+| Средний 50x50 | DFS | 0.9106 | 784.0 | 709.0 | да |
+| Средний 50x50 | A* | 2.0089 | 1133.0 | 709.0 | да |
+| Средний 50x50 | Dijkstra | 1.7041 | 1151.0 | 709.0 | да |
+| Большой 100x100 | BFS | 5.2983 | 4801.0 | 1685.0 | да |
+| Большой 100x100 | DFS | 2.5044 | 2155.0 | 1685.0 | да |
+| Большой 100x100 | A* | 8.6574 | 4791.0 | 1685.0 | да |
+| Большой 100x100 | Dijkstra | 7.1532 | 4800.0 | 1685.0 | да |
+| Пустой 50x50 | BFS | 2.8927 | 2304.0 | 95.0 | да |
+| Пустой 50x50 | DFS | 0.1404 | 187.0 | 95.0 | да |
+| Пустой 50x50 | A* | 0.2374 | 95.0 | 95.0 | да |
+| Пустой 50x50 | Dijkstra | 4.0408 | 2304.0 | 95.0 | да |
+| Без пути 30x30 | BFS | 0.0015 | 1.0 | 0.0 | нет |
+| Без пути 30x30 | DFS | 0.0013 | 1.0 | 0.0 | нет |
+| Без пути 30x30 | A* | 0.0016 | 1.0 | 0.0 | нет |
+| Без пути 30x30 | Dijkstra | 0.0018 | 1.0 | 0.0 | нет |
+
+CSV с результатами сохранен в `reports/results.csv`.
+
+Графики:
+
+| Лабиринт | Время | Посещенные клетки |
+|---|---|---|
+| Маленький |  |  |
+| Средний |  |  |
+| Большой |  |  |
+| Пустой |  |  |
+| Без пути |  |  |
+
+## 4. Анализ эффективности
+
+BFS гарантирует кратчайший путь в невзвешенном лабиринте. Это видно на маленьком
+лабиринте: BFS, A* и Дейкстра нашли путь длиной 20, а DFS нашел более длинный путь
+длиной 22. Недостаток BFS - широкий фронт поиска, из-за чего в пустом лабиринте он
+посетил все 2304 доступные клетки.
+
+DFS не гарантирует кратчайший путь, но часто работает быстро, потому что уходит
+глубоко по одному направлению. На маленьком лабиринте это дало путь хуже оптимального.
+На сгенерированных идеальных лабиринтах путь между двумя клетками единственный, поэтому
+DFS, BFS, A* и Дейкстра получили одинаковую длину пути.
+
+A* использует манхэттенскую эвристику. На пустом лабиринте он посетил только 95 клеток,
+то есть фактически прошел по оптимальному маршруту. В запутанных идеальных лабиринтах
+эвристика помогает слабее: прямое направление к выходу часто упирается в стены, поэтому
+A* посещает почти столько же клеток, сколько BFS, а из-за приоритетной очереди тратит
+больше времени.
+
+Дейкстра в невзвешенном лабиринте по результату близок к BFS, но работает медленнее
+из-за приоритетной очереди. Его преимущество проявляется при взвешенных клетках.
+В проекте Builder уже поддерживает символы `2`, `3` и `~` как клетки с повышенной
+стоимостью прохода, поэтому Дейкстру и A* можно использовать для дополнительного
+сравнения на взвешенных картах.
+
+Лабиринт "Без пути" проверяет корректную обработку отсутствия решения: стратегии
+возвращают пустой путь, а `MazeSolver` фиксирует длину 0.
+
+## 5. Выводы
+
+ООП позволило разделить предметную модель, загрузку данных, алгоритмы и интерфейс.
+Паттерн Builder делает формат входного файла заменяемым: можно добавить JSON-builder,
+не меняя `Maze` и стратегии. Strategy позволяет добавлять новые алгоритмы без правок
+в `MazeSolver`. Observer отделяет вычисления от вывода, а Command показывает, как
+инкапсулировать пользовательские действия и поддержать undo.
+
+Без этих паттернов код быстро стал бы монолитным: парсинг файла, поиск, статистика,
+печать и ручное управление оказались бы в одном месте. Тогда добавление нового формата,
+алгоритма или режима отображения требовало бы менять уже работающую логику.
diff --git a/shahovaa/zadanie 2/reports/results.csv b/shahovaa/zadanie 2/reports/results.csv
new file mode 100644
index 0000000..2acb085
--- /dev/null
+++ b/shahovaa/zadanie 2/reports/results.csv
@@ -0,0 +1,21 @@
+лабиринт,стратегия,время_мс,посещено_клеток,длина_пути,путь_найден,запусков
+Маленький 10x10,BFS,0.0423,37.0,20.0,да,10
+Маленький 10x10,DFS,0.0273,24.0,22.0,да,10
+Маленький 10x10,A*,0.0677,31.0,20.0,да,10
+Маленький 10x10,Dijkstra,0.0551,37.0,20.0,да,10
+Средний 50x50,BFS,1.2769,1151.0,709.0,да,10
+Средний 50x50,DFS,0.9106,784.0,709.0,да,10
+Средний 50x50,A*,2.0089,1133.0,709.0,да,10
+Средний 50x50,Dijkstra,1.7041,1151.0,709.0,да,10
+Большой 100x100,BFS,5.2983,4801.0,1685.0,да,10
+Большой 100x100,DFS,2.5044,2155.0,1685.0,да,10
+Большой 100x100,A*,8.6574,4791.0,1685.0,да,10
+Большой 100x100,Dijkstra,7.1532,4800.0,1685.0,да,10
+Пустой 50x50,BFS,2.8927,2304.0,95.0,да,10
+Пустой 50x50,DFS,0.1404,187.0,95.0,да,10
+Пустой 50x50,A*,0.2374,95.0,95.0,да,10
+Пустой 50x50,Dijkstra,4.0408,2304.0,95.0,да,10
+Без пути 30x30,BFS,0.0015,1.0,0.0,нет,10
+Без пути 30x30,DFS,0.0013,1.0,0.0,нет,10
+Без пути 30x30,A*,0.0016,1.0,0.0,нет,10
+Без пути 30x30,Dijkstra,0.0018,1.0,0.0,нет,10
diff --git a/shahovaa/zadanie 2/scripts/__init__.py b/shahovaa/zadanie 2/scripts/__init__.py
new file mode 100644
index 0000000..05aaea9
--- /dev/null
+++ b/shahovaa/zadanie 2/scripts/__init__.py
@@ -0,0 +1 @@
+"""Helper scripts for maze generation and experiments."""
diff --git a/shahovaa/zadanie 2/scripts/generate_mazes.py b/shahovaa/zadanie 2/scripts/generate_mazes.py
new file mode 100644
index 0000000..d403792
--- /dev/null
+++ b/shahovaa/zadanie 2/scripts/generate_mazes.py
@@ -0,0 +1,126 @@
+from __future__ import annotations
+
+import random
+from collections import deque
+from pathlib import Path
+
+
+ROOT = Path(__file__).resolve().parents[1]
+MAZE_DIR = ROOT / "data" / "mazes"
+
+
+def main() -> None:
+ generate_all()
+
+
+def generate_all() -> None:
+ MAZE_DIR.mkdir(parents=True, exist_ok=True)
+ _write("small.txt", _small_maze())
+ _write("medium.txt", _perfect_maze(50, 50, seed=2026))
+ _write("large.txt", _perfect_maze(100, 100, seed=2027))
+ _write("empty.txt", _empty_maze(50, 50))
+ _write("no_exit.txt", _no_path_maze(30, 30))
+
+
+def _write(filename: str, rows: list[str]) -> None:
+ (MAZE_DIR / filename).write_text("\n".join(rows) + "\n", encoding="utf-8")
+
+
+def _small_maze() -> list[str]:
+ return [
+ "##########",
+ "#S #E#",
+ "# #### # #",
+ "# # # #",
+ "# # #### #",
+ "# # #",
+ "# ###### #",
+ "# #",
+ "######## #",
+ "##########",
+ ]
+
+
+def _empty_maze(width: int, height: int) -> list[str]:
+ grid = _bordered_grid(width, height, fill=" ")
+ grid[1][1] = "S"
+ grid[height - 2][width - 2] = "E"
+ return _to_rows(grid)
+
+
+def _no_path_maze(width: int, height: int) -> list[str]:
+ grid = [["#" for _ in range(width)] for _ in range(height)]
+ grid[1][1] = "S"
+ grid[height - 2][width - 2] = "E"
+ return _to_rows(grid)
+
+
+def _perfect_maze(width: int, height: int, seed: int) -> list[str]:
+ if width < 5 or height < 5:
+ raise ValueError("Maze must be at least 5x5")
+
+ randomizer = random.Random(seed)
+ grid = [["#" for _ in range(width)] for _ in range(height)]
+ start = (1, 1)
+ stack = [start]
+ grid[start[1]][start[0]] = " "
+
+ while stack:
+ x, y = stack[-1]
+ candidates = []
+ for dx, dy in ((0, -2), (2, 0), (0, 2), (-2, 0)):
+ nx, ny = x + dx, y + dy
+ if 1 <= nx < width - 1 and 1 <= ny < height - 1 and grid[ny][nx] == "#":
+ candidates.append((nx, ny, dx, dy))
+
+ if not candidates:
+ stack.pop()
+ continue
+
+ nx, ny, dx, dy = randomizer.choice(candidates)
+ grid[y + dy // 2][x + dx // 2] = " "
+ grid[ny][nx] = " "
+ stack.append((nx, ny))
+
+ exit_x, exit_y = _farthest_open_cell(grid, start)
+ grid[start[1]][start[0]] = "S"
+ grid[exit_y][exit_x] = "E"
+ return _to_rows(grid)
+
+
+def _farthest_open_cell(grid: list[list[str]], start: tuple[int, int]) -> tuple[int, int]:
+ queue = deque([start])
+ distances = {start: 0}
+ farthest = start
+
+ while queue:
+ x, y = queue.popleft()
+ if distances[(x, y)] > distances[farthest]:
+ farthest = (x, y)
+
+ for dx, dy in ((0, -1), (1, 0), (0, 1), (-1, 0)):
+ nx, ny = x + dx, y + dy
+ if (nx, ny) not in distances and grid[ny][nx] != "#":
+ distances[(nx, ny)] = distances[(x, y)] + 1
+ queue.append((nx, ny))
+
+ return farthest
+
+
+def _bordered_grid(width: int, height: int, fill: str) -> list[list[str]]:
+ grid = [[fill for _ in range(width)] for _ in range(height)]
+ for x in range(width):
+ grid[0][x] = "#"
+ grid[height - 1][x] = "#"
+ for y in range(height):
+ grid[y][0] = "#"
+ grid[y][width - 1] = "#"
+ return grid
+
+
+def _to_rows(grid: list[list[str]]) -> list[str]:
+ return ["".join(row) for row in grid]
+
+
+if __name__ == "__main__":
+ main()
diff --git a/shahovaa/zadanie 2/scripts/run_experiments.py b/shahovaa/zadanie 2/scripts/run_experiments.py
new file mode 100644
index 0000000..46bd475
--- /dev/null
+++ b/shahovaa/zadanie 2/scripts/run_experiments.py
@@ -0,0 +1,194 @@
+from __future__ import annotations
+
+import csv
+import statistics
+import sys
+from collections import defaultdict
+from pathlib import Path
+
+
+ROOT = Path(__file__).resolve().parents[1]
+if str(ROOT) not in sys.path:
+ sys.path.insert(0, str(ROOT))
+
+from maze_solver import ( # noqa: E402
+ AStarStrategy,
+ BFSStrategy,
+ DFSStrategy,
+ DijkstraStrategy,
+ MazeSolver,
+ TextFileMazeBuilder,
+)
+from scripts.generate_mazes import generate_all # noqa: E402
+
+
+MAZES = [
+ ("small", "Маленький 10x10", ROOT / "data" / "mazes" / "small.txt"),
+ ("medium", "Средний 50x50", ROOT / "data" / "mazes" / "medium.txt"),
+ ("large", "Большой 100x100", ROOT / "data" / "mazes" / "large.txt"),
+ ("empty", "Пустой 50x50", ROOT / "data" / "mazes" / "empty.txt"),
+ ("no_exit", "Без пути 30x30", ROOT / "data" / "mazes" / "no_exit.txt"),
+]
+
+STRATEGIES = [BFSStrategy, DFSStrategy, AStarStrategy, DijkstraStrategy]
+REPORTS_DIR = ROOT / "reports"
+CHARTS_DIR = REPORTS_DIR / "charts"
+
+
+def main(runs: int = 10) -> None:
+ generate_all()
+ REPORTS_DIR.mkdir(parents=True, exist_ok=True)
+ CHARTS_DIR.mkdir(parents=True, exist_ok=True)
+
+ rows = _run_experiments(runs)
+ _write_csv(rows)
+ _write_charts(rows)
+ print(f"Wrote {REPORTS_DIR / 'results.csv'}")
+ print(f"Wrote SVG charts to {CHARTS_DIR}")
+
+
+def _run_experiments(runs: int) -> list[dict[str, object]]:
+ builder = TextFileMazeBuilder()
+ rows: list[dict[str, object]] = []
+
+ for maze_key, maze_name, maze_path in MAZES:
+ maze = builder.build_from_file(maze_path)
+ for strategy_type in STRATEGIES:
+ measurements = []
+ for _ in range(runs):
+ stats = MazeSolver(maze, strategy_type()).solve()
+ measurements.append(stats)
+
+ avg_time = statistics.fmean(item.time_ms for item in measurements)
+ avg_visited = statistics.fmean(item.visited_cells for item in measurements)
+ avg_path = statistics.fmean(item.path_length for item in measurements)
+ found = measurements[-1].path_length > 0
+ rows.append(
+ {
+ "key": maze_key,
+ "лабиринт": maze_name,
+ "стратегия": measurements[-1].strategy_name,
+ "время_мс": f"{avg_time:.4f}",
+ "посещено_клеток": f"{avg_visited:.1f}",
+ "длина_пути": f"{avg_path:.1f}",
+ "путь_найден": "да" if found else "нет",
+ "запусков": runs,
+ }
+ )
+
+ return rows
+
+
+def _write_csv(rows: list[dict[str, object]]) -> None:
+ csv_path = REPORTS_DIR / "results.csv"
+ headers = [
+ "лабиринт",
+ "стратегия",
+ "время_мс",
+ "посещено_клеток",
+ "длина_пути",
+ "путь_найден",
+ "запусков",
+ ]
+ with csv_path.open("w", encoding="utf-8", newline="") as stream:
+ writer = csv.DictWriter(stream, fieldnames=headers)
+ writer.writeheader()
+ for row in rows:
+ writer.writerow({header: row[header] for header in headers})
+
+
+def _write_charts(rows: list[dict[str, object]]) -> None:
+ grouped: dict[str, list[dict[str, object]]] = defaultdict(list)
+ for row in rows:
+ grouped[str(row["key"])].append(row)
+
+ for maze_key, group in grouped.items():
+ title = str(group[0]["лабиринт"])
+ _write_bar_chart(
+ CHARTS_DIR / f"{maze_key}_time.svg",
+ title=f"{title}: среднее время, мс",
+ rows=group,
+ metric="время_мс",
+ color="#2f6fbb",
+ )
+ _write_bar_chart(
+ CHARTS_DIR / f"{maze_key}_visited.svg",
+ title=f"{title}: посещенные клетки",
+ rows=group,
+ metric="посещено_клеток",
+ color="#2f8f5b",
+ )
+
+
+def _write_bar_chart(
+ path: Path,
+ title: str,
+ rows: list[dict[str, object]],
+ metric: str,
+ color: str,
+) -> None:
+ width = 780
+ height = 360
+ left = 72
+ right = 28
+ top = 54
+ bottom = 58
+ chart_width = width - left - right
+ chart_height = height - top - bottom
+ values = [float(row[metric]) for row in rows]
+ max_value = max(values) if values else 1.0
+ max_value = max_value or 1.0
+ bar_area = chart_width / len(rows)
+ bar_width = min(96, bar_area * 0.58)
+
+ parts = [
+ f'")
+ path.write_text("\n".join(parts), encoding="utf-8")
+
+
+def _escape(value: str) -> str:
+ return (
+ value.replace("&", "&")
+ .replace("<", "<")
+ .replace(">", ">")
+ .replace('"', """)
+ )
+
+
+if __name__ == "__main__":
+ main()
diff --git a/shahovaa/zadanie 2/tests/__init__.py b/shahovaa/zadanie 2/tests/__init__.py
new file mode 100644
index 0000000..a76b1eb
--- /dev/null
+++ b/shahovaa/zadanie 2/tests/__init__.py
@@ -0,0 +1 @@
+"""Unit tests for the maze solver project."""
diff --git a/shahovaa/zadanie 2/tests/test_solver.py b/shahovaa/zadanie 2/tests/test_solver.py
new file mode 100644
index 0000000..9fd5267
--- /dev/null
+++ b/shahovaa/zadanie 2/tests/test_solver.py
@@ -0,0 +1,64 @@
+from __future__ import annotations
+
+import tempfile
+import unittest
+from pathlib import Path
+
+from maze_solver import (
+ AStarStrategy,
+ BFSStrategy,
+ Direction,
+ MazeSolver,
+ MoveCommand,
+ Player,
+ TextFileMazeBuilder,
+)
+
+
+SIMPLE_MAZE = """\
+#######
+#S E#
+# ### #
+# #
+#######"""
+
+
+class MazeSolverTest(unittest.TestCase):
+ def build_maze(self):
+ with tempfile.TemporaryDirectory() as directory:
+ path = Path(directory) / "maze.txt"
+ path.write_text(SIMPLE_MAZE, encoding="utf-8")
+ return TextFileMazeBuilder().build_from_file(path)
+
+ def test_builder_reads_start_exit_and_neighbors(self) -> None:
+ maze = self.build_maze()
+
+ self.assertEqual((maze.start.x, maze.start.y), (1, 1))
+ self.assertEqual((maze.exit.x, maze.exit.y), (5, 1))
+ self.assertTrue(maze.get_cell(2, 1).is_passable())
+ self.assertFalse(maze.get_cell(0, 0).is_passable())
+
+ def test_bfs_and_astar_find_shortest_path(self) -> None:
+ maze = self.build_maze()
+
+ bfs_stats = MazeSolver(maze, BFSStrategy()).solve()
+ astar_stats = MazeSolver(maze, AStarStrategy()).solve()
+
+ self.assertEqual(bfs_stats.path_length, 5)
+ self.assertEqual(astar_stats.path_length, 5)
+ self.assertEqual(bfs_stats.path[0], maze.start)
+ self.assertEqual(bfs_stats.path[-1], maze.exit)
+
+ def test_move_command_can_execute_and_undo(self) -> None:
+ maze = self.build_maze()
+ player = Player.at_start(maze)
+ command = MoveCommand(player, Direction.RIGHT)
+
+ self.assertTrue(command.execute())
+ self.assertEqual((player.current_cell.x, player.current_cell.y), (2, 1))
+ self.assertTrue(command.undo())
+ self.assertEqual(player.current_cell, maze.start)
+
+
+if __name__ == "__main__":
+ unittest.main()