Add project structure with maze solver, simulator, and tests
TirSystem/github-action: Sync GitHub mirror metadata / sync-metadata (push) Failing after 3s
TirSystem/github-action: Sync GitHub mirror metadata / sync-metadata (push) Failing after 3s
This commit is contained in:
+35
@@ -0,0 +1,35 @@
|
||||
# Secrets
|
||||
.env
|
||||
|
||||
# Byte-compiled / cache
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
.pytest_cache/
|
||||
.mypy_cache/
|
||||
.ruff_cache/
|
||||
|
||||
# Virtual environments
|
||||
.venv/
|
||||
venv/
|
||||
env/
|
||||
|
||||
# Packaging
|
||||
build/
|
||||
dist/
|
||||
*.egg-info/
|
||||
.eggs/
|
||||
|
||||
# Coverage
|
||||
.coverage
|
||||
htmlcov/
|
||||
|
||||
# Doxygen output
|
||||
docs/html/
|
||||
docs/latex/
|
||||
|
||||
# IDEs / OS
|
||||
.idea/
|
||||
.vscode/
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
@@ -0,0 +1,14 @@
|
||||
# Minimal Doxygen configuration; unspecified options use Doxygen defaults.
|
||||
# Generate docs with: doxygen Doxyfile (output: docs/html/index.html)
|
||||
|
||||
PROJECT_NAME = "Reeborg Maze"
|
||||
PROJECT_BRIEF = "Right-wall-following maze solver for Reeborg's world"
|
||||
OUTPUT_DIRECTORY = docs
|
||||
INPUT = src
|
||||
FILE_PATTERNS = *.py
|
||||
RECURSIVE = YES
|
||||
OPTIMIZE_OUTPUT_JAVA = YES
|
||||
EXTRACT_ALL = YES
|
||||
GENERATE_HTML = YES
|
||||
GENERATE_LATEX = NO
|
||||
QUIET = YES
|
||||
@@ -0,0 +1,17 @@
|
||||
Copyright (C) 2026 Jens Tirsvad Nielsen
|
||||
|
||||
This program is free software: you can redistribute it and/or modify it under
|
||||
the terms of the GNU Affero General Public License as published by the Free
|
||||
Software Foundation, either version 3 of the License, or (at your option) any
|
||||
later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful, but WITHOUT
|
||||
ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
|
||||
FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
|
||||
details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License along
|
||||
with this program. If not, see <https://www.gnu.org/licenses/agpl-3.0.html>.
|
||||
|
||||
NOTE: This file holds the standard AGPL-3.0 notice only. Replace it with the
|
||||
full license text from https://www.gnu.org/licenses/agpl-3.0.txt before release.
|
||||
@@ -0,0 +1,106 @@
|
||||
# 🚀 Reeborg Maze
|
||||
|
||||
A beginner-friendly Python project that guides a robot through a maze in Reeborg's world by following the right wall, whatever its random start position and direction.
|
||||
|
||||
## 📚 Table of Contents
|
||||
|
||||
- [Overview](#-overview)
|
||||
- [Requirements](#-requirements)
|
||||
- [Setup](#-setup)
|
||||
- [Run](#-run)
|
||||
- [Tests](#-tests)
|
||||
- [License](#-license)
|
||||
- [Links](#-links)
|
||||
|
||||
## 🧭 Overview
|
||||
|
||||
This is the final project of day 6 of Udemy's *100 Days of Code: The Complete Python Pro Bootcamp*. The maze is fixed, but the robot starts at a random position and heading, so the program must work from anywhere.
|
||||
|
||||
The robot follows the right wall:
|
||||
|
||||
1. Walk forward until a wall is hit, then turn left (this puts a wall on the right and avoids infinite loops).
|
||||
2. Until the goal is reached:
|
||||
- if the right side is clear: turn right and move,
|
||||
- else if the front is clear: move,
|
||||
- else: turn left.
|
||||
|
||||
The project contains:
|
||||
|
||||
- `src/reeborg_maze/reeborg_script.py` – the script to paste into [Reeborg's world](https://reeborg.ca/reeborg.html?lang=en&mode=python&menu=worlds%2Fmenus%2Freeborg_intro_en.json&name=Maze&url=worlds%2Ftutorial_en%2Fmaze1.json).
|
||||
- `src/reeborg_maze/maze_solver.py` – the same algorithm as a testable function.
|
||||
- `src/reeborg_maze/simulator.py` – a small offline simulator that reads Reeborg world JSON files.
|
||||
- `src/reeborg_maze/visualizer.py` – draws the world as text and animates the robot.
|
||||
- `tests/` – unit tests and three test worlds with different start headings.
|
||||
|
||||
## 📋 Requirements
|
||||
|
||||
- Python 3.13 or newer
|
||||
- No runtime dependencies (pytest is installed as a dev dependency for the tests)
|
||||
- Optional: [Doxygen](https://www.doxygen.nl/) to build the API documentation
|
||||
|
||||
## 🛠️ Setup
|
||||
|
||||
Create and activate a local virtual environment, upgrade pip and install the project in editable mode.
|
||||
|
||||
Windows (PowerShell):
|
||||
|
||||
```powershell
|
||||
python -m venv .venv
|
||||
.venv\Scripts\Activate.ps1
|
||||
python -m pip install --upgrade pip
|
||||
python -m pip install -e ".[dev]"
|
||||
```
|
||||
|
||||
Linux / macOS:
|
||||
|
||||
```bash
|
||||
python3 -m venv .venv
|
||||
source .venv/bin/activate
|
||||
python -m pip install --upgrade pip
|
||||
python -m pip install -e ".[dev]"
|
||||
```
|
||||
|
||||
## ▶️ Run
|
||||
|
||||
In Reeborg's world: open the maze link above, choose Python mode, paste the contents of `src/reeborg_maze/reeborg_script.py` into the editor and press run.
|
||||
|
||||
Offline, against a world file:
|
||||
|
||||
```bash
|
||||
python -m reeborg_maze tests/worlds/problem_world.json
|
||||
```
|
||||
|
||||
Watch the robot move in the terminal (optionally pick a start with `X Y HEADING`, where heading is 0=east, 1=north, 2=west, 3=south):
|
||||
|
||||
```bash
|
||||
python -m reeborg_maze tests/worlds/problem_world.json --animate
|
||||
python -m reeborg_maze tests/worlds/problem_world.json --animate --delay 0.05 --start 1 1 0
|
||||
```
|
||||
|
||||

|
||||
|
||||
The robot is drawn as `>` `^` `<` `v`, the goal as `G` and mud as `~~~`.
|
||||
|
||||
Build the documentation (written to `docs/html`):
|
||||
|
||||
```bash
|
||||
doxygen Doxyfile
|
||||
```
|
||||
|
||||
## 🧪 Tests
|
||||
|
||||
The tests use [pytest](https://pytest.org); each world is solved from every free cell and all four headings.
|
||||
|
||||
```bash
|
||||
python -m pytest -v
|
||||
```
|
||||
|
||||
## 📄 License
|
||||
|
||||
[GNU AGPL v3 or later](LICENSE)
|
||||
|
||||
## 🔗 Links
|
||||
|
||||
- [Repository](https://git.tirsystem.com/Tirsvad-Udemy-100_days_of_code/006-a_robot_in_Reeborg_s_world)
|
||||
- [Documentation](https://git.tirsystem.com/Tirsvad-Udemy-100_days_of_code/006-a_robot_in_Reeborg_s_world#readme)
|
||||
- [Issue tracker](https://git.tirsystem.com/Tirsvad-Udemy-100_days_of_code/006-a_robot_in_Reeborg_s_world/issues)
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 12 KiB |
@@ -0,0 +1,28 @@
|
||||
[build-system]
|
||||
requires = ["setuptools>=68"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "reeborg-maze"
|
||||
version = "0.1.0"
|
||||
description = "Right-wall-following maze solver for Reeborg's world (100 Days of Code, day 6)."
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.13"
|
||||
license = "AGPL-3.0-or-later"
|
||||
license-files = ["LICENSE"]
|
||||
dependencies = []
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = ["pytest>=8"]
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
testpaths = ["tests"]
|
||||
|
||||
[project.urls]
|
||||
Repository = "https://git.tirsystem.com/Tirsvad-Udemy-100_days_of_code/006-a_robot_in_Reeborg_s_world"
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
where = ["src"]
|
||||
|
||||
[tool.setuptools.package-data]
|
||||
reeborg_maze = ["py.typed"]
|
||||
@@ -0,0 +1,9 @@
|
||||
"""!
|
||||
@file __init__.py
|
||||
@brief Right-wall-following maze solver for Reeborg's world.
|
||||
"""
|
||||
|
||||
from .maze_solver import solve_maze
|
||||
from .simulator import SimulatedRobot, load_world
|
||||
|
||||
__all__ = ["SimulatedRobot", "load_world", "solve_maze"]
|
||||
@@ -0,0 +1,41 @@
|
||||
"""!
|
||||
@file __main__.py
|
||||
@brief Command line entry point: python -m reeborg_maze WORLD.json
|
||||
"""
|
||||
|
||||
import argparse
|
||||
|
||||
from .constants import DEFAULT_FRAME_DELAY
|
||||
from .maze_solver import solve_maze
|
||||
from .simulator import SimulatedRobot, load_world
|
||||
from .visualizer import make_frame_printer, render
|
||||
|
||||
|
||||
def main() -> None:
|
||||
"""!
|
||||
@brief Solve a world file offline and report the final position.
|
||||
"""
|
||||
parser = argparse.ArgumentParser(description="Solve a Reeborg maze offline.")
|
||||
parser.add_argument("world", help="path to a Reeborg world JSON file")
|
||||
parser.add_argument("--animate", action="store_true",
|
||||
help="show the robot moving in the terminal")
|
||||
parser.add_argument("--delay", type=float, default=DEFAULT_FRAME_DELAY,
|
||||
help="seconds between animation frames (default: %(default)s)")
|
||||
parser.add_argument("--start", nargs=3, type=int, metavar=("X", "Y", "HEADING"),
|
||||
help="override start; heading 0=E 1=N 2=W 3=S")
|
||||
args = parser.parse_args()
|
||||
|
||||
world = load_world(args.world)
|
||||
x, y, heading = args.start if args.start else (None, None, None)
|
||||
robot = SimulatedRobot(world, x, y, heading)
|
||||
if args.animate:
|
||||
robot.on_action = make_frame_printer(args.delay)
|
||||
robot.on_action(robot)
|
||||
solve_maze(robot)
|
||||
if not args.animate:
|
||||
print(render(robot))
|
||||
print(f"Goal reached at ({robot.x}, {robot.y}) in {robot.actions} actions.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,50 @@
|
||||
"""!
|
||||
@file constants.py
|
||||
@brief Constants shared by the maze solver and the local world simulator.
|
||||
"""
|
||||
|
||||
## Compass headings in Reeborg's world, counter-clockwise: east, north, west, south.
|
||||
EAST: int = 0
|
||||
NORTH: int = 1
|
||||
WEST: int = 2
|
||||
SOUTH: int = 3
|
||||
|
||||
## Number of headings; used for modular turning.
|
||||
HEADING_COUNT: int = 4
|
||||
|
||||
## Unit step (dx, dy) for each heading. Reeborg's y axis grows northwards.
|
||||
HEADING_STEP: dict[int, tuple[int, int]] = {
|
||||
EAST: (1, 0),
|
||||
NORTH: (0, 1),
|
||||
WEST: (-1, 0),
|
||||
SOUTH: (0, -1),
|
||||
}
|
||||
|
||||
## Wall names used in Reeborg world JSON files, keyed by heading.
|
||||
HEADING_WALL_NAME: dict[int, str] = {
|
||||
EAST: "east",
|
||||
NORTH: "north",
|
||||
WEST: "west",
|
||||
SOUTH: "south",
|
||||
}
|
||||
|
||||
## Tiles the robot cannot enter.
|
||||
BLOCKING_TILES: frozenset[str] = frozenset({"mud", "water"})
|
||||
|
||||
## Safety limit on simulator actions, so a bad algorithm cannot loop forever.
|
||||
MAX_ACTIONS: int = 10_000
|
||||
|
||||
## Characters that show the robot's heading in the text visualisation.
|
||||
HEADING_SYMBOL: dict[int, str] = {
|
||||
EAST: ">",
|
||||
NORTH: "^",
|
||||
WEST: "<",
|
||||
SOUTH: "v",
|
||||
}
|
||||
|
||||
## Default pause between animation frames, in seconds.
|
||||
DEFAULT_FRAME_DELAY: float = 0.15
|
||||
|
||||
## ANSI escape sequences: clear the screen, and move the cursor to the top left.
|
||||
ANSI_CLEAR: str = "\x1b[2J"
|
||||
ANSI_HOME: str = "\x1b[H"
|
||||
@@ -0,0 +1,54 @@
|
||||
"""!
|
||||
@file maze_solver.py
|
||||
@brief Right-wall-following algorithm that works on any robot-like object.
|
||||
"""
|
||||
|
||||
from typing import Protocol
|
||||
|
||||
|
||||
class Robot(Protocol):
|
||||
"""!
|
||||
@brief The subset of Reeborg's API the solver needs.
|
||||
"""
|
||||
|
||||
def move(self) -> None: ...
|
||||
def turn_left(self) -> None: ...
|
||||
def front_is_clear(self) -> bool: ...
|
||||
def right_is_clear(self) -> bool: ...
|
||||
def at_goal(self) -> bool: ...
|
||||
|
||||
|
||||
def turn_right(robot: Robot) -> None:
|
||||
"""!
|
||||
@brief Turn the robot 90 degrees clockwise (three left turns).
|
||||
@param robot The robot to turn.
|
||||
"""
|
||||
for _ in range(3):
|
||||
robot.turn_left()
|
||||
|
||||
|
||||
def solve_maze(robot: Robot) -> None:
|
||||
"""!
|
||||
@brief Drive the robot to the goal by following the wall on its right.
|
||||
@param robot Robot with a random start position and heading.
|
||||
|
||||
The robot first walks straight until it hits a wall, then turns left so
|
||||
that the wall is on its right. This avoids the infinite loop that happens
|
||||
when the robot starts in open space and the right side is never blocked.
|
||||
Then, until the goal is reached:
|
||||
- right is clear: turn right and move,
|
||||
- else front is clear: move,
|
||||
- else: turn left.
|
||||
"""
|
||||
while robot.front_is_clear():
|
||||
robot.move()
|
||||
robot.turn_left()
|
||||
|
||||
while not robot.at_goal():
|
||||
if robot.right_is_clear():
|
||||
turn_right(robot)
|
||||
robot.move()
|
||||
elif robot.front_is_clear():
|
||||
robot.move()
|
||||
else:
|
||||
robot.turn_left()
|
||||
@@ -0,0 +1,29 @@
|
||||
"""!
|
||||
@file reeborg_script.py
|
||||
@brief Self-contained script to paste into https://reeborg.ca (Python mode).
|
||||
|
||||
It uses only Reeborg's built-in functions, so it has no imports.
|
||||
"""
|
||||
|
||||
|
||||
def turn_right():
|
||||
"""!
|
||||
@brief Turn clockwise by doing three left turns.
|
||||
"""
|
||||
turn_left()
|
||||
turn_left()
|
||||
turn_left()
|
||||
|
||||
|
||||
while front_is_clear():
|
||||
move()
|
||||
turn_left()
|
||||
|
||||
while not at_goal():
|
||||
if right_is_clear():
|
||||
turn_right()
|
||||
move()
|
||||
elif front_is_clear():
|
||||
move()
|
||||
else:
|
||||
turn_left()
|
||||
@@ -0,0 +1,122 @@
|
||||
"""!
|
||||
@file simulator.py
|
||||
@brief Minimal offline model of Reeborg's world, used to test the solver.
|
||||
"""
|
||||
|
||||
import json
|
||||
from collections.abc import Callable
|
||||
from pathlib import Path
|
||||
|
||||
from .constants import (
|
||||
BLOCKING_TILES,
|
||||
HEADING_COUNT,
|
||||
HEADING_STEP,
|
||||
HEADING_WALL_NAME,
|
||||
MAX_ACTIONS,
|
||||
)
|
||||
|
||||
|
||||
class SimulationError(RuntimeError):
|
||||
"""!
|
||||
@brief Raised when the robot hits a wall or exceeds the action limit.
|
||||
"""
|
||||
|
||||
|
||||
class SimulatedRobot:
|
||||
"""!
|
||||
@brief A robot on a walled grid that mimics Reeborg's built-in API.
|
||||
"""
|
||||
|
||||
def __init__(self, world: dict, x: int | None = None, y: int | None = None,
|
||||
heading: int | None = None,
|
||||
on_action: Callable[["SimulatedRobot"], None] | None = None) -> None:
|
||||
"""!
|
||||
@param world Parsed Reeborg world JSON.
|
||||
@param x Start column; defaults to the robot in the file.
|
||||
@param y Start row; defaults to the robot in the file.
|
||||
@param heading Start heading (0=E, 1=N, 2=W, 3=S); defaults to the file's.
|
||||
@param on_action Optional callback invoked after every turn or move.
|
||||
"""
|
||||
start = world["robots"][0]
|
||||
self.cols: int = world["cols"]
|
||||
self.rows: int = world["rows"]
|
||||
self.x: int = start["x"] if x is None else x
|
||||
self.y: int = start["y"] if y is None else y
|
||||
self.heading: int = start["_orientation"] if heading is None else heading
|
||||
self.walls: dict[str, list[str]] = world.get("walls", {})
|
||||
self.tiles: dict[str, list[str]] = world.get("tiles", {})
|
||||
goal = world["goal"]["position"]
|
||||
self.goal: tuple[int, int] = (goal["x"], goal["y"])
|
||||
self.actions: int = 0
|
||||
self.on_action = on_action
|
||||
|
||||
def _tick(self) -> None:
|
||||
self.actions += 1
|
||||
if self.actions > MAX_ACTIONS:
|
||||
raise SimulationError("Action limit exceeded: probable infinite loop")
|
||||
|
||||
def _notify(self) -> None:
|
||||
if self.on_action is not None:
|
||||
self.on_action(self)
|
||||
|
||||
def wall_between(self, x: int, y: int, heading: int) -> bool:
|
||||
"""!
|
||||
@brief Whether a wall or the world edge blocks leaving (x, y) toward heading.
|
||||
"""
|
||||
dx, dy = HEADING_STEP[heading]
|
||||
nx, ny = x + dx, y + dy
|
||||
if not (1 <= nx <= self.cols and 1 <= ny <= self.rows):
|
||||
return True
|
||||
name = HEADING_WALL_NAME[heading]
|
||||
opposite = HEADING_WALL_NAME[(heading + 2) % HEADING_COUNT]
|
||||
return (
|
||||
name in self.walls.get(f"{x},{y}", [])
|
||||
or opposite in self.walls.get(f"{nx},{ny}", [])
|
||||
)
|
||||
|
||||
def _clear(self, heading: int) -> bool:
|
||||
if self.wall_between(self.x, self.y, heading):
|
||||
return False
|
||||
dx, dy = HEADING_STEP[heading]
|
||||
target = self.tiles.get(f"{self.x + dx},{self.y + dy}", [])
|
||||
return not BLOCKING_TILES.intersection(target)
|
||||
|
||||
def front_is_clear(self) -> bool:
|
||||
"""!@brief True if the robot can move forward."""
|
||||
return self._clear(self.heading)
|
||||
|
||||
def right_is_clear(self) -> bool:
|
||||
"""!@brief True if the robot could move after turning right."""
|
||||
return self._clear((self.heading - 1) % HEADING_COUNT)
|
||||
|
||||
def at_goal(self) -> bool:
|
||||
"""!@brief True if the robot stands on the goal."""
|
||||
return (self.x, self.y) == self.goal
|
||||
|
||||
def turn_left(self) -> None:
|
||||
"""!@brief Rotate 90 degrees counter-clockwise."""
|
||||
self._tick()
|
||||
self.heading = (self.heading + 1) % HEADING_COUNT
|
||||
self._notify()
|
||||
|
||||
def move(self) -> None:
|
||||
"""!
|
||||
@brief Move one cell forward.
|
||||
@throws SimulationError if blocked.
|
||||
"""
|
||||
self._tick()
|
||||
if not self.front_is_clear():
|
||||
raise SimulationError(f"Blocked at ({self.x}, {self.y}) heading {self.heading}")
|
||||
dx, dy = HEADING_STEP[self.heading]
|
||||
self.x += dx
|
||||
self.y += dy
|
||||
self._notify()
|
||||
|
||||
|
||||
def load_world(path: str | Path) -> dict:
|
||||
"""!
|
||||
@brief Read a Reeborg world JSON file.
|
||||
@param path File to read.
|
||||
@return Parsed world dictionary.
|
||||
"""
|
||||
return json.loads(Path(path).read_text(encoding="utf-8"))
|
||||
@@ -0,0 +1,69 @@
|
||||
"""!
|
||||
@file visualizer.py
|
||||
@brief Text rendering and animation of a SimulatedRobot in its world.
|
||||
"""
|
||||
|
||||
import time
|
||||
|
||||
from .constants import (
|
||||
ANSI_CLEAR,
|
||||
ANSI_HOME,
|
||||
BLOCKING_TILES,
|
||||
EAST,
|
||||
HEADING_SYMBOL,
|
||||
NORTH,
|
||||
SOUTH,
|
||||
WEST,
|
||||
)
|
||||
from .simulator import SimulatedRobot
|
||||
|
||||
|
||||
def render(robot: SimulatedRobot) -> str:
|
||||
"""!
|
||||
@brief Draw the world as text: walls, mud, goal (G) and the robot (> ^ < v).
|
||||
@param robot Robot whose world and position are drawn.
|
||||
@return Multi-line string.
|
||||
"""
|
||||
lines = []
|
||||
for y in range(robot.rows, 0, -1):
|
||||
top = "".join(
|
||||
"+" + ("---" if robot.wall_between(x, y, NORTH) else " ")
|
||||
for x in range(1, robot.cols + 1)
|
||||
)
|
||||
lines.append(top + "+")
|
||||
|
||||
row = ""
|
||||
for x in range(1, robot.cols + 1):
|
||||
row += "|" if robot.wall_between(x, y, WEST) else " "
|
||||
if (x, y) == (robot.x, robot.y):
|
||||
row += f" {HEADING_SYMBOL[robot.heading]} "
|
||||
elif (x, y) == robot.goal:
|
||||
row += " G "
|
||||
elif BLOCKING_TILES.intersection(robot.tiles.get(f"{x},{y}", [])):
|
||||
row += "~~~"
|
||||
else:
|
||||
row += " "
|
||||
row += "|" if robot.wall_between(robot.cols, y, EAST) else " "
|
||||
lines.append(row)
|
||||
|
||||
lines.append("".join(
|
||||
"+" + ("---" if robot.wall_between(x, 1, SOUTH) else " ")
|
||||
for x in range(1, robot.cols + 1)
|
||||
) + "+")
|
||||
lines.append(f"actions: {robot.actions} position: ({robot.x}, {robot.y})")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def make_frame_printer(delay: float):
|
||||
"""!
|
||||
@brief Build an on_action callback that redraws the world in place.
|
||||
@param delay Seconds to pause after each frame.
|
||||
@return Callback accepting a SimulatedRobot.
|
||||
"""
|
||||
print(ANSI_CLEAR, end="")
|
||||
|
||||
def show(robot: SimulatedRobot) -> None:
|
||||
print(ANSI_HOME + render(robot), flush=True)
|
||||
time.sleep(delay)
|
||||
|
||||
return show
|
||||
@@ -0,0 +1,49 @@
|
||||
"""!
|
||||
@file test_maze_solver.py
|
||||
@brief Runs the solver on every provided world from all four start headings.
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from reeborg_maze import SimulatedRobot, load_world, solve_maze
|
||||
from reeborg_maze.constants import BLOCKING_TILES, HEADING_COUNT
|
||||
|
||||
WORLDS = sorted((Path(__file__).parent / "worlds").glob("*.json"))
|
||||
|
||||
|
||||
def free_starts() -> list[tuple[str, int, int, int]]:
|
||||
"""!
|
||||
@brief Every (world, x, y, heading) where the robot can start.
|
||||
"""
|
||||
starts = []
|
||||
for path in WORLDS:
|
||||
world = load_world(path)
|
||||
for x in range(1, world["cols"] + 1):
|
||||
for y in range(1, world["rows"] + 1):
|
||||
if BLOCKING_TILES.intersection(world["tiles"].get(f"{x},{y}", [])):
|
||||
continue
|
||||
for heading in range(HEADING_COUNT):
|
||||
starts.append((path.name, x, y, heading))
|
||||
return starts
|
||||
|
||||
|
||||
def test_worlds_exist() -> None:
|
||||
assert WORLDS
|
||||
|
||||
|
||||
@pytest.mark.parametrize("path", WORLDS, ids=lambda p: p.name)
|
||||
def test_reaches_goal_from_file_start(path: Path) -> None:
|
||||
robot = SimulatedRobot(load_world(path))
|
||||
solve_maze(robot)
|
||||
assert robot.at_goal()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("name", "x", "y", "heading"), free_starts())
|
||||
def test_reaches_goal_from_every_free_cell_and_heading(
|
||||
name: str, x: int, y: int, heading: int
|
||||
) -> None:
|
||||
robot = SimulatedRobot(load_world(Path(__file__).parent / "worlds" / name), x, y, heading)
|
||||
solve_maze(robot)
|
||||
assert robot.at_goal()
|
||||
@@ -0,0 +1,248 @@
|
||||
{
|
||||
"walls": {
|
||||
"1,1": [
|
||||
"east"
|
||||
],
|
||||
"1,2": [
|
||||
"east"
|
||||
],
|
||||
"1,3": [
|
||||
"north"
|
||||
],
|
||||
"2,5": [
|
||||
"north"
|
||||
],
|
||||
"1,5": [
|
||||
"north"
|
||||
],
|
||||
"3,5": [
|
||||
"north"
|
||||
],
|
||||
"4,5": [
|
||||
"north"
|
||||
],
|
||||
"5,5": [
|
||||
"north",
|
||||
"east"
|
||||
],
|
||||
"2,3": [
|
||||
"north",
|
||||
"east"
|
||||
],
|
||||
"2,2": [
|
||||
"east"
|
||||
],
|
||||
"3,2": [
|
||||
"east"
|
||||
],
|
||||
"5,1": [
|
||||
"east"
|
||||
],
|
||||
"5,3": [
|
||||
"east",
|
||||
"north"
|
||||
],
|
||||
"4,4": [
|
||||
"east",
|
||||
"north"
|
||||
],
|
||||
"4,2": [
|
||||
"east"
|
||||
],
|
||||
"1,4": [
|
||||
"east"
|
||||
],
|
||||
"5,2": [
|
||||
"east",
|
||||
"north"
|
||||
],
|
||||
"3,1": [
|
||||
"north"
|
||||
]
|
||||
},
|
||||
"small_tiles": false,
|
||||
"rows": 6,
|
||||
"cols": 6,
|
||||
"tiles": {
|
||||
"6,1": [
|
||||
"mud"
|
||||
],
|
||||
"6,2": [
|
||||
"mud"
|
||||
],
|
||||
"6,3": [
|
||||
"mud"
|
||||
],
|
||||
"6,5": [
|
||||
"mud"
|
||||
],
|
||||
"6,6": [
|
||||
"mud"
|
||||
],
|
||||
"5,6": [
|
||||
"mud"
|
||||
],
|
||||
"4,6": [
|
||||
"mud"
|
||||
],
|
||||
"3,6": [
|
||||
"mud"
|
||||
],
|
||||
"2,6": [
|
||||
"mud"
|
||||
],
|
||||
"1,6": [
|
||||
"mud"
|
||||
],
|
||||
"6,4": [
|
||||
"grass"
|
||||
],
|
||||
"5,4": [
|
||||
"#000000"
|
||||
],
|
||||
"5,5": [
|
||||
"#000000"
|
||||
],
|
||||
"4,5": [
|
||||
"#000000"
|
||||
],
|
||||
"3,5": [
|
||||
"#000000"
|
||||
],
|
||||
"3,4": [
|
||||
"#000000"
|
||||
],
|
||||
"4,4": [
|
||||
"#000000"
|
||||
],
|
||||
"4,3": [
|
||||
"#000000"
|
||||
],
|
||||
"3,3": [
|
||||
"#000000"
|
||||
],
|
||||
"3,2": [
|
||||
"#000000"
|
||||
],
|
||||
"4,2": [
|
||||
"#000000"
|
||||
],
|
||||
"5,3": [
|
||||
"#000000"
|
||||
],
|
||||
"5,2": [
|
||||
"#000000"
|
||||
],
|
||||
"5,1": [
|
||||
"#000000"
|
||||
],
|
||||
"4,1": [
|
||||
"#000000"
|
||||
],
|
||||
"3,1": [
|
||||
"#000000"
|
||||
],
|
||||
"2,1": [
|
||||
"#000000"
|
||||
],
|
||||
"2,2": [
|
||||
"#000000"
|
||||
],
|
||||
"2,3": [
|
||||
"#000000"
|
||||
],
|
||||
"2,4": [
|
||||
"#000000"
|
||||
],
|
||||
"2,5": [
|
||||
"#000000"
|
||||
],
|
||||
"1,5": [
|
||||
"#000000"
|
||||
],
|
||||
"1,4": [
|
||||
"#000000"
|
||||
],
|
||||
"1,3": [
|
||||
"#000000"
|
||||
],
|
||||
"1,2": [
|
||||
"#000000"
|
||||
],
|
||||
"1,1": [
|
||||
"#000000"
|
||||
]
|
||||
},
|
||||
"goal": {
|
||||
"possible_final_positions": [
|
||||
[
|
||||
6,
|
||||
4
|
||||
]
|
||||
],
|
||||
"position": {
|
||||
"image": "racing_flag",
|
||||
"x": 6,
|
||||
"y": 4
|
||||
}
|
||||
},
|
||||
"robots": [
|
||||
{
|
||||
"x": 3,
|
||||
"y": 4,
|
||||
"objects": {},
|
||||
"model": "classic",
|
||||
"_orientation": 3,
|
||||
"_is_leaky": true,
|
||||
"_prev_x": 3,
|
||||
"_prev_y": 3,
|
||||
"_prev_orientation": 2,
|
||||
"_trace_history": [
|
||||
{
|
||||
"color": "seagreen",
|
||||
"thickness": 2,
|
||||
"prev_x": 140,
|
||||
"x": 140,
|
||||
"prev_y": 170,
|
||||
"y": 210,
|
||||
"grid_x": 3,
|
||||
"grid_y": 3
|
||||
},
|
||||
{
|
||||
"color": "seagreen",
|
||||
"thickness": 2,
|
||||
"prev_x": 140,
|
||||
"x": 140,
|
||||
"prev_y": 200,
|
||||
"y": 210,
|
||||
"grid_x": 3,
|
||||
"grid_y": 3
|
||||
}
|
||||
],
|
||||
"_trace_style": "default",
|
||||
"_trace_color": "seagreen",
|
||||
"__id": 80,
|
||||
"initial_position": [
|
||||
3,
|
||||
3
|
||||
]
|
||||
}
|
||||
],
|
||||
"description": [
|
||||
"",
|
||||
"<h1>Lost in a maze</h1>",
|
||||
"<p>Reeborg was exploring a dark maze and the battery in its flashlight ran out.</p>",
|
||||
"<p>Write a program using an <code>if/elif/else</code> statement so Reeborg can find the exit. ",
|
||||
"The secret is to have Reeborg follow along the right edge of the maze, ",
|
||||
"turning right if it can, going straight ahead if it ",
|
||||
"can’t turn right, or turning left as a last resort.</p>",
|
||||
"<h3>What you need to know</h3>",
|
||||
"<ul><li>The functions <code>move()</code> and <code>turn_left()</code>.</li>",
|
||||
"<li>Either the test <code>front_is_clear()</code> or <code>wall_in_front()</code>,",
|
||||
"<code>right_is_clear()</code> or <code>wall_on_right()</code>, and <code>at_goal()</code>.</li>",
|
||||
"<li>How to use a <code>while</code> loop and <code>if/elif/else</code> statements.</li>",
|
||||
"<li>It might be useful to know how to use the negation of a test (<code>not</code> in Python).</li></ul>",
|
||||
"DIFFICULTY4",
|
||||
""
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,248 @@
|
||||
{
|
||||
"walls": {
|
||||
"1,1": [
|
||||
"east"
|
||||
],
|
||||
"1,2": [
|
||||
"east"
|
||||
],
|
||||
"1,3": [
|
||||
"north"
|
||||
],
|
||||
"2,5": [
|
||||
"north"
|
||||
],
|
||||
"1,5": [
|
||||
"north"
|
||||
],
|
||||
"3,5": [
|
||||
"north"
|
||||
],
|
||||
"4,5": [
|
||||
"north"
|
||||
],
|
||||
"5,5": [
|
||||
"north",
|
||||
"east"
|
||||
],
|
||||
"2,3": [
|
||||
"north",
|
||||
"east"
|
||||
],
|
||||
"2,2": [
|
||||
"east"
|
||||
],
|
||||
"3,2": [
|
||||
"east"
|
||||
],
|
||||
"5,1": [
|
||||
"east"
|
||||
],
|
||||
"5,3": [
|
||||
"east",
|
||||
"north"
|
||||
],
|
||||
"4,4": [
|
||||
"east",
|
||||
"north"
|
||||
],
|
||||
"4,2": [
|
||||
"east"
|
||||
],
|
||||
"1,4": [
|
||||
"east"
|
||||
],
|
||||
"5,2": [
|
||||
"east",
|
||||
"north"
|
||||
],
|
||||
"3,1": [
|
||||
"north"
|
||||
]
|
||||
},
|
||||
"small_tiles": false,
|
||||
"rows": 6,
|
||||
"cols": 6,
|
||||
"tiles": {
|
||||
"6,1": [
|
||||
"mud"
|
||||
],
|
||||
"6,2": [
|
||||
"mud"
|
||||
],
|
||||
"6,3": [
|
||||
"mud"
|
||||
],
|
||||
"6,5": [
|
||||
"mud"
|
||||
],
|
||||
"6,6": [
|
||||
"mud"
|
||||
],
|
||||
"5,6": [
|
||||
"mud"
|
||||
],
|
||||
"4,6": [
|
||||
"mud"
|
||||
],
|
||||
"3,6": [
|
||||
"mud"
|
||||
],
|
||||
"2,6": [
|
||||
"mud"
|
||||
],
|
||||
"1,6": [
|
||||
"mud"
|
||||
],
|
||||
"6,4": [
|
||||
"grass"
|
||||
],
|
||||
"5,4": [
|
||||
"#000000"
|
||||
],
|
||||
"5,5": [
|
||||
"#000000"
|
||||
],
|
||||
"4,5": [
|
||||
"#000000"
|
||||
],
|
||||
"3,5": [
|
||||
"#000000"
|
||||
],
|
||||
"3,4": [
|
||||
"#000000"
|
||||
],
|
||||
"4,4": [
|
||||
"#000000"
|
||||
],
|
||||
"4,3": [
|
||||
"#000000"
|
||||
],
|
||||
"3,3": [
|
||||
"#000000"
|
||||
],
|
||||
"3,2": [
|
||||
"#000000"
|
||||
],
|
||||
"4,2": [
|
||||
"#000000"
|
||||
],
|
||||
"5,3": [
|
||||
"#000000"
|
||||
],
|
||||
"5,2": [
|
||||
"#000000"
|
||||
],
|
||||
"5,1": [
|
||||
"#000000"
|
||||
],
|
||||
"4,1": [
|
||||
"#000000"
|
||||
],
|
||||
"3,1": [
|
||||
"#000000"
|
||||
],
|
||||
"2,1": [
|
||||
"#000000"
|
||||
],
|
||||
"2,2": [
|
||||
"#000000"
|
||||
],
|
||||
"2,3": [
|
||||
"#000000"
|
||||
],
|
||||
"2,4": [
|
||||
"#000000"
|
||||
],
|
||||
"2,5": [
|
||||
"#000000"
|
||||
],
|
||||
"1,5": [
|
||||
"#000000"
|
||||
],
|
||||
"1,4": [
|
||||
"#000000"
|
||||
],
|
||||
"1,3": [
|
||||
"#000000"
|
||||
],
|
||||
"1,2": [
|
||||
"#000000"
|
||||
],
|
||||
"1,1": [
|
||||
"#000000"
|
||||
]
|
||||
},
|
||||
"goal": {
|
||||
"possible_final_positions": [
|
||||
[
|
||||
6,
|
||||
4
|
||||
]
|
||||
],
|
||||
"position": {
|
||||
"image": "racing_flag",
|
||||
"x": 6,
|
||||
"y": 4
|
||||
}
|
||||
},
|
||||
"robots": [
|
||||
{
|
||||
"x": 3,
|
||||
"y": 4,
|
||||
"objects": {},
|
||||
"model": "classic",
|
||||
"_orientation": 1,
|
||||
"_is_leaky": true,
|
||||
"_prev_x": 3,
|
||||
"_prev_y": 3,
|
||||
"_prev_orientation": 2,
|
||||
"_trace_history": [
|
||||
{
|
||||
"color": "seagreen",
|
||||
"thickness": 2,
|
||||
"prev_x": 140,
|
||||
"x": 140,
|
||||
"prev_y": 170,
|
||||
"y": 210,
|
||||
"grid_x": 3,
|
||||
"grid_y": 3
|
||||
},
|
||||
{
|
||||
"color": "seagreen",
|
||||
"thickness": 2,
|
||||
"prev_x": 140,
|
||||
"x": 140,
|
||||
"prev_y": 200,
|
||||
"y": 210,
|
||||
"grid_x": 3,
|
||||
"grid_y": 3
|
||||
}
|
||||
],
|
||||
"_trace_style": "default",
|
||||
"_trace_color": "seagreen",
|
||||
"__id": 80,
|
||||
"initial_position": [
|
||||
3,
|
||||
3
|
||||
]
|
||||
}
|
||||
],
|
||||
"description": [
|
||||
"",
|
||||
"<h1>Lost in a maze</h1>",
|
||||
"<p>Reeborg was exploring a dark maze and the battery in its flashlight ran out.</p>",
|
||||
"<p>Write a program using an <code>if/elif/else</code> statement so Reeborg can find the exit. ",
|
||||
"The secret is to have Reeborg follow along the right edge of the maze, ",
|
||||
"turning right if it can, going straight ahead if it ",
|
||||
"can’t turn right, or turning left as a last resort.</p>",
|
||||
"<h3>What you need to know</h3>",
|
||||
"<ul><li>The functions <code>move()</code> and <code>turn_left()</code>.</li>",
|
||||
"<li>Either the test <code>front_is_clear()</code> or <code>wall_in_front()</code>,",
|
||||
"<code>right_is_clear()</code> or <code>wall_on_right()</code>, and <code>at_goal()</code>.</li>",
|
||||
"<li>How to use a <code>while</code> loop and <code>if/elif/else</code> statements.</li>",
|
||||
"<li>It might be useful to know how to use the negation of a test (<code>not</code> in Python).</li></ul>",
|
||||
"DIFFICULTY4",
|
||||
""
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,248 @@
|
||||
{
|
||||
"walls": {
|
||||
"1,1": [
|
||||
"east"
|
||||
],
|
||||
"1,2": [
|
||||
"east"
|
||||
],
|
||||
"1,3": [
|
||||
"north"
|
||||
],
|
||||
"2,5": [
|
||||
"north"
|
||||
],
|
||||
"1,5": [
|
||||
"north"
|
||||
],
|
||||
"3,5": [
|
||||
"north"
|
||||
],
|
||||
"4,5": [
|
||||
"north"
|
||||
],
|
||||
"5,5": [
|
||||
"north",
|
||||
"east"
|
||||
],
|
||||
"2,3": [
|
||||
"north",
|
||||
"east"
|
||||
],
|
||||
"2,2": [
|
||||
"east"
|
||||
],
|
||||
"3,2": [
|
||||
"east"
|
||||
],
|
||||
"5,1": [
|
||||
"east"
|
||||
],
|
||||
"5,3": [
|
||||
"east",
|
||||
"north"
|
||||
],
|
||||
"4,4": [
|
||||
"east",
|
||||
"north"
|
||||
],
|
||||
"4,2": [
|
||||
"east"
|
||||
],
|
||||
"1,4": [
|
||||
"east"
|
||||
],
|
||||
"5,2": [
|
||||
"east",
|
||||
"north"
|
||||
],
|
||||
"3,1": [
|
||||
"north"
|
||||
]
|
||||
},
|
||||
"small_tiles": false,
|
||||
"rows": 6,
|
||||
"cols": 6,
|
||||
"tiles": {
|
||||
"6,1": [
|
||||
"mud"
|
||||
],
|
||||
"6,2": [
|
||||
"mud"
|
||||
],
|
||||
"6,3": [
|
||||
"mud"
|
||||
],
|
||||
"6,5": [
|
||||
"mud"
|
||||
],
|
||||
"6,6": [
|
||||
"mud"
|
||||
],
|
||||
"5,6": [
|
||||
"mud"
|
||||
],
|
||||
"4,6": [
|
||||
"mud"
|
||||
],
|
||||
"3,6": [
|
||||
"mud"
|
||||
],
|
||||
"2,6": [
|
||||
"mud"
|
||||
],
|
||||
"1,6": [
|
||||
"mud"
|
||||
],
|
||||
"6,4": [
|
||||
"grass"
|
||||
],
|
||||
"5,4": [
|
||||
"#000000"
|
||||
],
|
||||
"5,5": [
|
||||
"#000000"
|
||||
],
|
||||
"4,5": [
|
||||
"#000000"
|
||||
],
|
||||
"3,5": [
|
||||
"#000000"
|
||||
],
|
||||
"3,4": [
|
||||
"#000000"
|
||||
],
|
||||
"4,4": [
|
||||
"#000000"
|
||||
],
|
||||
"4,3": [
|
||||
"#000000"
|
||||
],
|
||||
"3,3": [
|
||||
"#000000"
|
||||
],
|
||||
"3,2": [
|
||||
"#000000"
|
||||
],
|
||||
"4,2": [
|
||||
"#000000"
|
||||
],
|
||||
"5,3": [
|
||||
"#000000"
|
||||
],
|
||||
"5,2": [
|
||||
"#000000"
|
||||
],
|
||||
"5,1": [
|
||||
"#000000"
|
||||
],
|
||||
"4,1": [
|
||||
"#000000"
|
||||
],
|
||||
"3,1": [
|
||||
"#000000"
|
||||
],
|
||||
"2,1": [
|
||||
"#000000"
|
||||
],
|
||||
"2,2": [
|
||||
"#000000"
|
||||
],
|
||||
"2,3": [
|
||||
"#000000"
|
||||
],
|
||||
"2,4": [
|
||||
"#000000"
|
||||
],
|
||||
"2,5": [
|
||||
"#000000"
|
||||
],
|
||||
"1,5": [
|
||||
"#000000"
|
||||
],
|
||||
"1,4": [
|
||||
"#000000"
|
||||
],
|
||||
"1,3": [
|
||||
"#000000"
|
||||
],
|
||||
"1,2": [
|
||||
"#000000"
|
||||
],
|
||||
"1,1": [
|
||||
"#000000"
|
||||
]
|
||||
},
|
||||
"goal": {
|
||||
"possible_final_positions": [
|
||||
[
|
||||
6,
|
||||
4
|
||||
]
|
||||
],
|
||||
"position": {
|
||||
"image": "racing_flag",
|
||||
"x": 6,
|
||||
"y": 4
|
||||
}
|
||||
},
|
||||
"robots": [
|
||||
{
|
||||
"x": 3,
|
||||
"y": 4,
|
||||
"objects": {},
|
||||
"model": "classic",
|
||||
"_orientation": 2,
|
||||
"_is_leaky": true,
|
||||
"_prev_x": 3,
|
||||
"_prev_y": 3,
|
||||
"_prev_orientation": 2,
|
||||
"_trace_history": [
|
||||
{
|
||||
"color": "seagreen",
|
||||
"thickness": 2,
|
||||
"prev_x": 140,
|
||||
"x": 140,
|
||||
"prev_y": 170,
|
||||
"y": 210,
|
||||
"grid_x": 3,
|
||||
"grid_y": 3
|
||||
},
|
||||
{
|
||||
"color": "seagreen",
|
||||
"thickness": 2,
|
||||
"prev_x": 140,
|
||||
"x": 140,
|
||||
"prev_y": 200,
|
||||
"y": 210,
|
||||
"grid_x": 3,
|
||||
"grid_y": 3
|
||||
}
|
||||
],
|
||||
"_trace_style": "default",
|
||||
"_trace_color": "seagreen",
|
||||
"__id": 80,
|
||||
"initial_position": [
|
||||
3,
|
||||
3
|
||||
]
|
||||
}
|
||||
],
|
||||
"description": [
|
||||
"",
|
||||
"<h1>Lost in a maze</h1>",
|
||||
"<p>Reeborg was exploring a dark maze and the battery in its flashlight ran out.</p>",
|
||||
"<p>Write a program using an <code>if/elif/else</code> statement so Reeborg can find the exit. ",
|
||||
"The secret is to have Reeborg follow along the right edge of the maze, ",
|
||||
"turning right if it can, going straight ahead if it ",
|
||||
"can’t turn right, or turning left as a last resort.</p>",
|
||||
"<h3>What you need to know</h3>",
|
||||
"<ul><li>The functions <code>move()</code> and <code>turn_left()</code>.</li>",
|
||||
"<li>Either the test <code>front_is_clear()</code> or <code>wall_in_front()</code>,",
|
||||
"<code>right_is_clear()</code> or <code>wall_on_right()</code>, and <code>at_goal()</code>.</li>",
|
||||
"<li>How to use a <code>while</code> loop and <code>if/elif/else</code> statements.</li>",
|
||||
"<li>It might be useful to know how to use the negation of a test (<code>not</code> in Python).</li></ul>",
|
||||
"DIFFICULTY4",
|
||||
""
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user