From bca920c62cae08a0c6bd7542a2b48f09e1427a66 Mon Sep 17 00:00:00 2001 From: Jens Tirsvad Nielsen Date: Sun, 4 Oct 2026 12:07:00 +0800 Subject: [PATCH] Add project structure with maze solver, simulator, and tests --- .gitignore | 35 ++++ Doxyfile | 14 ++ LICENSE | 17 ++ README.md | 106 ++++++++++++ docs/demo.webp | Bin 0 -> 12112 bytes pyproject.toml | 28 ++++ src/reeborg_maze/__init__.py | 9 ++ src/reeborg_maze/__main__.py | 41 +++++ src/reeborg_maze/constants.py | 50 ++++++ src/reeborg_maze/maze_solver.py | 54 +++++++ src/reeborg_maze/py.typed | 0 src/reeborg_maze/reeborg_script.py | 29 ++++ src/reeborg_maze/simulator.py | 122 ++++++++++++++ src/reeborg_maze/visualizer.py | 69 ++++++++ tests/test_maze_solver.py | 49 ++++++ tests/worlds/problem_world.json | 248 +++++++++++++++++++++++++++++ tests/worlds/problem_world2.json | 248 +++++++++++++++++++++++++++++ tests/worlds/problem_world3.json | 248 +++++++++++++++++++++++++++++ 18 files changed, 1367 insertions(+) create mode 100644 .gitignore create mode 100644 Doxyfile create mode 100644 LICENSE create mode 100644 README.md create mode 100644 docs/demo.webp create mode 100644 pyproject.toml create mode 100644 src/reeborg_maze/__init__.py create mode 100644 src/reeborg_maze/__main__.py create mode 100644 src/reeborg_maze/constants.py create mode 100644 src/reeborg_maze/maze_solver.py create mode 100644 src/reeborg_maze/py.typed create mode 100644 src/reeborg_maze/reeborg_script.py create mode 100644 src/reeborg_maze/simulator.py create mode 100644 src/reeborg_maze/visualizer.py create mode 100644 tests/test_maze_solver.py create mode 100644 tests/worlds/problem_world.json create mode 100644 tests/worlds/problem_world2.json create mode 100644 tests/worlds/problem_world3.json diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6272c1e --- /dev/null +++ b/.gitignore @@ -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 diff --git a/Doxyfile b/Doxyfile new file mode 100644 index 0000000..6d8a616 --- /dev/null +++ b/Doxyfile @@ -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 diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..6c0a786 --- /dev/null +++ b/LICENSE @@ -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 . + +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. diff --git a/README.md b/README.md new file mode 100644 index 0000000..281c547 --- /dev/null +++ b/README.md @@ -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 +``` + +![Animated run of the robot solving the maze in the terminal](docs/demo.webp) + +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) diff --git a/docs/demo.webp b/docs/demo.webp new file mode 100644 index 0000000000000000000000000000000000000000..18e7c50718472e1131ebc0d2026bf96b685676b5 GIT binary patch literal 12112 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 I{0jNM0E(k18vp=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"] diff --git a/src/reeborg_maze/__init__.py b/src/reeborg_maze/__init__.py new file mode 100644 index 0000000..a5a4d84 --- /dev/null +++ b/src/reeborg_maze/__init__.py @@ -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"] diff --git a/src/reeborg_maze/__main__.py b/src/reeborg_maze/__main__.py new file mode 100644 index 0000000..b207d42 --- /dev/null +++ b/src/reeborg_maze/__main__.py @@ -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() diff --git a/src/reeborg_maze/constants.py b/src/reeborg_maze/constants.py new file mode 100644 index 0000000..0e15dcd --- /dev/null +++ b/src/reeborg_maze/constants.py @@ -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" diff --git a/src/reeborg_maze/maze_solver.py b/src/reeborg_maze/maze_solver.py new file mode 100644 index 0000000..9f3d3b5 --- /dev/null +++ b/src/reeborg_maze/maze_solver.py @@ -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() diff --git a/src/reeborg_maze/py.typed b/src/reeborg_maze/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/src/reeborg_maze/reeborg_script.py b/src/reeborg_maze/reeborg_script.py new file mode 100644 index 0000000..ebcfc9e --- /dev/null +++ b/src/reeborg_maze/reeborg_script.py @@ -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() diff --git a/src/reeborg_maze/simulator.py b/src/reeborg_maze/simulator.py new file mode 100644 index 0000000..be07c09 --- /dev/null +++ b/src/reeborg_maze/simulator.py @@ -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")) diff --git a/src/reeborg_maze/visualizer.py b/src/reeborg_maze/visualizer.py new file mode 100644 index 0000000..34153d6 --- /dev/null +++ b/src/reeborg_maze/visualizer.py @@ -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 diff --git a/tests/test_maze_solver.py b/tests/test_maze_solver.py new file mode 100644 index 0000000..749e51b --- /dev/null +++ b/tests/test_maze_solver.py @@ -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() diff --git a/tests/worlds/problem_world.json b/tests/worlds/problem_world.json new file mode 100644 index 0000000..00738f4 --- /dev/null +++ b/tests/worlds/problem_world.json @@ -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": [ + "", + "

Lost in a maze

", + "

Reeborg was exploring a dark maze and the battery in its flashlight ran out.

", + "

Write a program using an if/elif/else 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.

", + "

What you need to know

", + "
  • The functions move() and turn_left().
  • ", + "
  • Either the test front_is_clear() or wall_in_front(),", + "right_is_clear() or wall_on_right(), and at_goal().
  • ", + "
  • How to use a while loop and if/elif/else statements.
  • ", + "
  • It might be useful to know how to use the negation of a test (not in Python).
", + "DIFFICULTY4", + "" + ] +} diff --git a/tests/worlds/problem_world2.json b/tests/worlds/problem_world2.json new file mode 100644 index 0000000..46d6679 --- /dev/null +++ b/tests/worlds/problem_world2.json @@ -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": [ + "", + "

Lost in a maze

", + "

Reeborg was exploring a dark maze and the battery in its flashlight ran out.

", + "

Write a program using an if/elif/else 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.

", + "

What you need to know

", + "
  • The functions move() and turn_left().
  • ", + "
  • Either the test front_is_clear() or wall_in_front(),", + "right_is_clear() or wall_on_right(), and at_goal().
  • ", + "
  • How to use a while loop and if/elif/else statements.
  • ", + "
  • It might be useful to know how to use the negation of a test (not in Python).
", + "DIFFICULTY4", + "" + ] +} diff --git a/tests/worlds/problem_world3.json b/tests/worlds/problem_world3.json new file mode 100644 index 0000000..b080679 --- /dev/null +++ b/tests/worlds/problem_world3.json @@ -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": [ + "", + "

Lost in a maze

", + "

Reeborg was exploring a dark maze and the battery in its flashlight ran out.

", + "

Write a program using an if/elif/else 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.

", + "

What you need to know

", + "
  • The functions move() and turn_left().
  • ", + "
  • Either the test front_is_clear() or wall_in_front(),", + "right_is_clear() or wall_on_right(), and at_goal().
  • ", + "
  • How to use a while loop and if/elif/else statements.
  • ", + "
  • It might be useful to know how to use the negation of a test (not in Python).
", + "DIFFICULTY4", + "" + ] +}