Note that the hour of the deadline is not the same as for homework assignments.
On the day of the deadline, it may not be possible for the instructor to answer questions received after 3pm.
Collaboration is prohibited. We want to evaluate you on the basis of what you can do by consulting the resources listed below, and without help from anyone other than course staff. Seeking or giving aid on this assignment is prohibited (except for asking course staff); doing so constitutes academic misconduct which can have serious consequences. If you are unsure about whether something is allowed, ask. The course syllabus contains more information about the course and university policies regarding academic honesty.
You'll use recursion with backtracking to explore solutions to a game that involves traversing a maze containing various kinds of obstacles.
Ask if you're not sure whether a resource falls into one of these categories, or if you think I missed a resource that seems necessary to complete the assignment.
Ask your instructor or TA a question by email, in office hours, or on discord.
According to Greek mythology, the labyrinth was a vast and complex maze designed and constructed by the master inventor Daedalus on the island of Crete. A half-human, half-bull creature called the Minotaur was then imprisoned within the labyrinth. Some years later, Theseus (son of the king of Athens) sailed to Crete, navigated the labyrinth, and killed the Minotaur. Theseus returned to Athens and was hailed as a hero.
Let's imagine an a different version of the story where the Minotaur is warned of an imminent attack by Theseus and plans an escape from the labyrinth. We'll make this idea more precise and then give a programming project based on the scenario.
We suppose the labyrinth has unclimbable stone walls and a stone floor, but it is open to the sky above. (It is often depicted this way in artwork based on the myth.) We suppose the layout is based on a rectangular grid, where some of the grid squares (or cells, as we'll call them) are entirely filled by stone walls. Having spent many long years in a lair at the center of the labyrinth, the Minotaur does not know the full floor plan. However, Daedalus left behind a few hundred drawings of potential designs, and it is nearly certain one of them shows the final design. A speedy escape requires preparing a computer program that can analyze all of the potential maps quickly.
The labyrinth is not just a maze, for in addition to its winding and branching passages, there are three types of obstacles:
Thankfully, the Minotaur has ideas about how to overcome each type of obstacle:
The plan to use wings to deal with spring traps is the trickiest and most dangerous part of the plan. The Minotaur is not sure how far it will be possible to glide after hitting a spring trap, so plans will need to be made for various estimates of the flight range.
In this project you play the role of the Minotaur, writing a program to solve labyrinths littered with obstacles under various constraints (e.g. presence or absence of a plank, number of keys, limited glide distance).
It is a ZIP archive, i.e. a group of files bundled together and compressed into a single file. The first thing you need to do is to extract the starter pack into a directory where you will do your work. Contact the instructor or TA if you need assistance extracting a ZIP file. Windows, MacOS, and most Linux distributions come with a ZIP extractor either built into the operating system or pre-installed.
If you want to browse the starter pack files on github, you can also do that. But the ZIP is the easiest way to download all of them at once (as you must do):
You will not edit any of the files in the starter pack. They provide a module with classes to represent a labyrinth or to create various examples for testing purposes.
labyrinth.py
- A module defining a class Labyrinth
and subclasses which can generate random labyrinths (without obstacles) or load example labyrinths provided for testing.plane.py
- Planar geometry module as developed in class (with the updated __eq__
method that was added recently, and which is needed by labyrinth.py
)A labyrinth is represented by an object of class Labyrinth
(defined in labyrinth.py
) which represents a grid of square cells that contain walls, empty space, or obstacles. It is in some ways similar to the class Maze
we wrote in lecture, with a few differences as well as extra features to handle the obstacle types.
The functions you write will take a Labyrinth
as an argument, and one of them will also need to know the Minotaur's status, that is, the Minotaur's current equipment and flight characteristics. (When using recursion to explore the labyrinth, this may change, e.g. as keys are used up).
The status of the Minotaur will always be specified by a dictionary with the following keys and value types:
key | value type | meaning |
---|---|---|
"plank" | bool | True means a plank is carried (can walk over pits) |
"keys" | int | Number of brass keys (consumed by opening locks) |
"flight_range" | int or float | Maximum glide distance (max dist from spring trap to exit) |
Here is an example of such a status dictionary:
status = {
"plank": True, # boolean, answers "can pits be traversed?"
"keys": 5, # integer, number of brass keys
"flight_range": 0, # integer or float, maximum distance the Minotaur can glide
# using wax wings (0 means wings are not worn)
}
Write a Python module minotaur.py
that contains the following functions. That is the only file you should submit to Gradescope.
solution(lab,status,path=None)
¶Given a Labyrinth
object lab
and a dictionary status
describing the equipment and characteristics of the Minotaur, searches for a solution to the labyrinth, i.e. a path from the start to the goal.
Returns either a list of Point2
objects representing a solution path, or the value None
if no solution exists under the given conditions.
A solution must take the following into account:
status["plank"]
must be True
), because without it the pits are deadly. Recall the plank can be used multiple times.status["flight_range"]
, meaning that the Minotaur can glide all the way to the exit after being flung into the air by the trap and thus being seen by the approaching attackers. Also, to indicate that the Minotaur does this, the next entry in the solution list must be the location of goal of the labyrinth.Notice that the solution might pass through many locks and pit traps, but spring traps are a "one-time-only" event. A solution has either no spring traps, or exactly one, and in the latter case the spring trap is the second-to-last point in the solution.
Except in the case of the jump from a spring trap to the goal, neighboring points in a solution list must be horizontal or vertical neighbors.
has_solution(lab, status)
¶Given a Labyrinth
object lab
and a dictionary status
describing the equipment and characteristics of the Minotaur, determines whether or not it is possible to solve the labyrinth under the initial conditions desrcibed by status
. Returns a boolean value, with True
indicating that such a solution exists.
requires_plank(lab)
¶Given a Labyrinth
object lab
, determines whether it is possible to escape but a plank is absolutely necessary to do so. That is, returns True
if:
Otherwise, returns False
. Equivalently, returns False
if:
requires_flight(lab)
¶Given a Labyrinth
object lab
, determines whether it is possible to escape but stepping on a spring trap and gliding to the goal is absolutely necessary to do so. That is, returns True
if:
Otherwise, returns False
. Equivalently, returns False
if:
min_required_keys(lab)
¶Given a Labyrinth
object lab
that can be solved under some set of conditions, returns the smallest number of keys that allow the labyrinth to be solved (an integer). For example:
0
means that it is possible to solve the labyrinth without having any keys (though possibly it requires use of a plank and/or flight)1
means that this labyrinth cannot be solved without keys, but there is a way to solve it if the Minotaur begins with one key (though possibly it requires use of a plank and/or flight).2
means this labyrinth cannot be solved with just one key, but it can be solved if the Minotaur begins with two keys.And so on.
Also, if this function is called with a labyrinth that cannot be solved at all, it should raise ValueError
because the question doesn't make sense in that case.
Just as we wrote a maze solver that used methods of the Maze
class to inquire about the walls and empty spaces and find a solution, the functions you will write need to inquire about aspects of the labyrinth.
The Labyrinth
class defined in labyrinth.py
has methods for this purpose. They are documented in this section.
The labyrinth is divided into a rectangular grid of cells, with xsize
columns and ysize
rows. The location of a cell is specified by a Point2
object with integer coordinates.
The content of any cell is indicated by an integer, the cell type, which is one of these values in the table below. For each cell type, there is also a global constant in the module labyrinth
that contains the value. Using these named constants can help make your code more readable (compare if ct == 40:
to if ct == labyrinth.CT_SPRING:
).
Cell type (int) | Cell type (constant) | Meaning |
---|---|---|
0 | labyrinth.CT_WALL | Filled with stone wall |
1 | labyrinth.CT_START | The Minotaur's lair, the starting point |
2 | labyrinth.CT_GOAL | The exit from the labyrinth |
3 | labyrinth.CT_EMPTY | Empty space |
20 | labyrinth.CT_PIT | A pit with deadly spikes at the bottom |
30 | labyrinth.CT_LOCK | You can pass through only by using up one key |
40 | labyrinth.CT_SPRING | A spring trap that hurls you into the air |
Labyrinth
class that you can use in minotaur.py
¶These are methods and attributes that every Labyrinth
object has, and which you are allowed use in minotaur.py
. In describing method, we omit the implicit self
argument and just write it method the way it would be called.
start
¶Read-only. An attribute of type Point2
indicating where the Minotaur's lair is. That is where any solution must start. Equivalently, the return value is the location of the only cell whose type would be 1
/labyrinth.CT_START
.
goal
¶Read-only. An attribute of type Point2
indicating where the exit/goal is. That is where any solution must end. Equivalently, the return value is the location of the only cell whose type would be 2
/labyrinth.CT_GOAL
.
get_cell(p)
¶Returns an integer giving the type of cell located at Point2
object p
.
non_wall_neighbors(p)
¶Returns a list of Point2
objects that are horizontal or vertical neighbors of the cell at Point2
object p
, and which do not contain walls. (They may be of any cell type other than 0
/labyrinth.CT_WALL
.)
is_*
¶Several methods take a Point2
object p
and return a boolean indicating whether it is a specific cell type. Here is a list of them:
is_wall(p)
- checks for type 0
/labyrinth.CT_WALL
is_start(p)
- checks for type 1
/labyrinth.CT_START
is_goal(p)
- checks for type 2
/labyrinth.CT_GOAL
is_empty(p)
- checks for type 3
/labyrinth.CT_EMPTY
is_pit(p)
- checks for type 20
/labyrinth.CT_PIT
is_lock(p)
- checks for type 30
/labyrinth.CT_LOCK
is_spring(p)
- checks for type 40
/labyrinth.CT_SPRING
num_*
¶Several methods take no arguments and return the number of some type of cell in this labyrinth. Here is a list of them:
num_pits()
- returns number of pits (cells of type 20
/labyrinth.CT_PIT
)num_locks()
- returns number of locks (cells of type 30
/labyrinth.CT_LOCK
)num_springs()
- returns number of spring traps (cells of type 40
/labyrinth.CT_SPRING
)Labyrinth
class that you CANNOT use in minotaur.py
¶These allow you to make changes to a labyrinth, which is not allowed in minotaur.py
since those functions only examine existing characteristics. These methods are useful for making labyrinth objects, which you will need to do in order to test your work.
As before the methods are written in the form you'd call them, omitting self
and using the class name instead of __init__
for the constructor.
Labyrinth(xsize, ysize)
¶Makes a new labyrinth of the given x- and y-sizes (integers). Initially, every cell is a wall.
apply_empty_interior
¶Makes every cell that isn't on the edge of the grid an empty cell (type 3
/labyrinth.CT_EMPTY
). Thus the labyrinth is a big square of walls with nothing inside.
set_cell(p, k)
¶Sets the type of cell located at Point2
object p
to the integer cell type k
. Any type except start and goal can be set this way. To set the start or goal, either assign to the attribute directly or call set_start
/set_goal
(documented below).
set_*
¶Several methods take a Point2
object p
and set the type of cell at that location to a specific value. In most cases this is equivalent to calling set_cell(p,k)
for certain value of k
. These convenience methods are:
set_wall(p)
- Make p
a cell of type 0
/labyrinth.CT_WALL
set_start(p)
- Make p
the start of the labyrinth (type 1
/labyrinth.CT_START
). If a starting location was already set, the previous starting cell will revert back to whatever type it was before it was made the start (or 0
/labyrinth.CT_WALL
if no previous value existed).set_goal(p)
- Make p
the goal/exit of the labyrinth (type 2
/labyrinth.CT_GOAL
). If a goal location was already set, the previous goal cell will revert back to whatever type it was before it was made the goal (or 0
/labyrinth.CT_WALL
if no previous value existed).set_empty(p)
- Make p
a cell of type 3
/labyrinth.CT_EMPTY
set_pit(p)
- Make p
a cell of type 20
/labyrinth.CT_PIT
set_lock(p)
- Make p
a cell of type 30
/labyrinth.CT_LOCK
set_spring(p)
- Make p
a cell of type 40
/labyrinth.CT_SPRING
save(fp)
¶If fp
is a file object that is currently open for writing, saves a copy of this labyrinth to that file. Sample usage:
# suppose `L` is a `Labyrinth` object
# in particular `labyrinth` has already been imported and used
with open("mylabyrinth.dat","w") as fp:
L.save(fp)
The method L.save(fp)
writes a labyrinth object L
to a file. To read a labyrinth from a file and return the corresponding object, you need to call Labyrinth.load(fp)
with fp
a file open for reading. Note you don't create a labyrinth instance directly in this case; you call .load
using the class name Labyrinth
. It makes an instance and returns it. Sample usage:
from labyrinth import Labyrinth
with open("mylabyrinth.dat","r") as fp:
L = Labyrinth.load(fp)
# Now `L` is a `Labyrinth` object with the layout previously saved to that file
labyrinth.py
¶sample_labyrinth(n)
¶THIS SECTION RECENTLY UPDATED
This function returns a sample labyrinth with identifier n
, which can be an integer or a string.
If n
is an integer, it must be between 0
and 8
. The returned labyrinth is one of the samples first provided as part of the starter pack. The returned value will be a labyrinth that also has a .info
attribute to tell you about the example's properties.
If n
is a string, then this function returns one of the named autograder labyrinths. These are labyrinths used in the project 2 autograder, and the report from the autograder may mention a specific labyrinth by name when describing a failed test.
SampleLabyrinth.builtin_labyrinth_names()
¶THIS SECTION RECENTLY ADDED
Call this function to get a list of all named sample labyrinths (i.e. labyrinths used by the autograder) that are available. Note that there are also numbered sample labyrinths that are not included in this list (they have identifiers 0
,1
,2
,3
,4
,5
,6
,7
,8
).
Here is the list it currently returns:
from labyrinth import SampleLabyrinth
SampleLabyrinth.builtin_labyrinth_names()
RandomLabyrinth
¶A subclass of Labyrinth
. Call RandomLabyrinth(xsize,ysize)
with both sizes odd to return a Labyrinth with its start
and goal
set, with an interesting maze of passages, but which has only walls and empty cells. You would want to add some obstacles if you are testing those features. Uses Prim's algorithm.
Labyrinth
has __str__
and __repr__
methods that produce text graphics of the labyrinth. Cell types are represented by different characters, as shown here:
Char | Cell type |
---|---|
█ | Wall |
S | Start |
G | Goal |
Empty cell | |
o | Pit |
? | Lock |
^ | Spring trap |
Here's an example.
from labyrinth import *
L = sample_labyrinth(7)
print(L)
Calling the method as_image()
of a Labyrinth
object will return an image object that Jupyter can display directly in the notebook. (Displaying inline images this way doesn't seem to work in VS Code's notebook editing system.) This is convenient to see mazes that you're working on, e.g.
from labyrinth import *
L = sample_labyrinth(7)
L.as_image()
and just like the image-saving methods in our Maze
class, you can ask for a set of cells to be highlighted.
L.as_image(highlight=[
Point2(21,17),
Point2(21,16),
Point2(21,15),
Point2(21,14),
Point2(21,13),
Point2(20,13),
Point2(19,13),
Point2(18,13)
])
In these graphical representations, obstacles are distinguished by icons with unique colors and shapes. Pass a keyword argument scale
with a larger integer value to see a higher-resolution picture.
L = sample_labyrinth(5)
L.as_image(scale=5)
L = sample_labyrinth(5)
L.as_image(scale=25)
Here is a key to the colors and icon shapes used for the different obstacles:
Labyrinth
also has a save_png(fn)
method that works just like the one from Maze
, saving a picture of the labyrinth to a file. Like Maze.save_png
, it can also accept a highlight
argument to highlight some cells (in light blue).
Example:
# Write sample labyrinth #2 to a PNG file
from labyrinth import *
L = sample_labyrinth(2)
L.save_png("sample_labyrinth_2.png",scale=10)
THIS SECTION RECENTLY ADDED
The function solution(...)
is the most important, because the other functions are sure to depend on it to do their work. It will also account for most of the autograder points. Give it the attention it deserves!
CT_*
constants¶THIS SECTION RECENTLY ADDED
The code in minotaur.py
will be much more readable if you use the named constants like CT_EMPTY
instead of integer literals like 3
. To that end, I suggest that you make the first line of actual code in minotaur.py
either
from labyrinth import *
which makes everything in labyrinth.py
available, OR
from labyrinth import CT_EMPTY, CT_PIT, CT_START, CT_SPRING, CT_WALL, CT_GOAL, CT_LOCK
which selectively imports just the CT_*
constants.
The task described above is complicated because there are several types of obstacles. Here is how I suggest you work
Let's call a labyrinth:
RandomLabyrinth(...)
The autograder will run tests of your functions for examples of each level, whenever possible. (For example, requires_flight
doesn't make sense to test on labyrinths of level 3 or lower.)
Thus, it would be a good idea to start by writing and testing a solution(...)
function that works for labyrinths of level 1. That will be most similar to the maze solver we covered in class.
Then, move up to level 2, adding additional logic to the solution
function and testing it. At this point it becomes possible to write requires_plank
as well.
Continue in this way, adding features and functions as appropriate to each level.
I suggest you test minotaur.py
using a notebook, where the usual sequence would be:
minotaur.py
minotaur.py
on that labyrinth and examine the results. Check by hand or by comparing against info given for the example labyrinths.Here's an example of what your import cell might look like:
# Suggested starting point for notebook work with your project
from plane import Point2, Vector2
from labyrinth import * # to avoid labyrinth.--- everywhere
from minotaur import * # gets all functions defined in minotaur module
Then, a test might look like
L = RandomLabyrinth(xsize=13,ysize=7) # starting point: tree maze
# Now you'd add some additional open space with
# calls to L.set_empty and maybe some obstacles
# with calls to L.set_pit, L.set_spring, L.set_lock
# ...
# show the labyrinth (text graphics)
print(L)
# show a solution for a particular situation (no plank, one key, can glide distance 2)
st = {
"plank": False,
"keys": 1,
"flight_range": 2,
}
print()
print(solution(L,st)) # if there is a solution, shows it; else prints None
There's an issue I think may cause you trouble when you work on this project, and I want to give some advice to prevent that.
Short version: If a dictionary is passed to a function, don't modify the dictionary inside the function. If you think there is a reason to do so, instead make a copy of that dictionary and change the copy.
In more detail: Python dictionaries are mutable, which can create some surprises. For example, if you pass an integer variable as an argument to a function and then modify the argument inside the function, the global integer variable is unchanged:
def change(x):
x += 5
print(x,"is the new value of my argument, in the function")
x = 3
print(x) # 3
change(x)
print(x," <---- UNCHANGED") # still 3
But if you pass a dictionary and then change the value associated with some key, it does affect the global dictionary:
def change_value_in(d):
d["a"] += 5
print(d["a"],"is the new value associated to 'a', in the function")
d = {"a":3, "b": -2, "c":"perfunctory derision"}
print(d["a"])
change_value_in(d)
print(d["a"]," <---- CHANGED GLOBALLY?!")
This happens because d["a"] += 5
is really a method call on d
, so it's not that you're replacing d
with a new value (which would be local), rather you're asking the global d
to do something for you (modify one of its values).
Often this means it is a bad idea to modify a dictionary inside a function. Instead, make a copy and modify the copy. Here's an expression that creates a copy of a dictionary named d
.
{ k:d[k] for k in d } # this expression produces a copy of `d`, a dictionary
Here's how it might be used in the example above.
def change_value_in(d):
dcopy = {k:d[k] for k in d}
dcopy["a"] += 5
print(dcopy["a"],"is the new value associated to 'a' in my local copy")
d = {"a":3, "b": -2, "c":"perfunctory derision"}
print(d["a"])
change_value_in(d)
print(d["a"]," <---- UNCHANGED")
Your project must follow the rules in the MCS 275 coding standards document. In addition:
Only submit minotaur.py
- The autograder will supply everything else.
No changes to existing classes - Obviously it would be a bad idea to change anything in the files provided in the sample pack, since the autograder will use fresh unmodified copies when testing your work.
No output - The functions in minotaur.py
must not print anything.
No modification of status dictionary - After calling a function from minotaur.py
that accepts a status dictionary, the values in that dictionary must not be changed. Rather than making changes, create new dictionaries as needed.
Clean import - Importing minotaur.py
should not do anything other than defining functions. It's fine to include test code, but put it inside a if __name__=="__main__":
block so it only runs when the file is executed as a script.
Make proper use of structured programming - Several of the functions in minotaur.py
are most naturally written by calling other functions in the same file. Make use of that technique when appropriate, rather than having lots of duplicated or near-duplicate code.
No use of float
except for distance comparison - The only time you can use float values is to compute and compare distances between points, i.e. to determine whether the flight_distance
in a status dictionary is greater than the distance between the current cell and the goal. Remember that Vector2
has an abs()
method so you can compute the distance from p
to q
with abs(q-p)
!
Reasonable efficiency - The autograder will allow up to 5 seconds for each test case to run, and will not give credit if a test runs longer than that. These tests will use labyrinths sized so that a reference solution based on the techniques covered in lecture returns an answer in a small fraction of a second, so there is considerable leeway for a correct answer to have some level of inefficiency and still pass the tests. However, functions that can in principle return the correct answer but only after doing a huge amount of unnecessary or inefficient work may lose credit due to these time-outs.
The autograder tests your program and grades it based on its behavior. A series of tests will be run checking the return values and behavior of the functions in minotaur.py
against the specifications in this document.
I will review your code and look for adherence to the coding standards and other rules given above, and I will examine your method of solving the problem. If I see that your program does not do what was requested in this document, but the error was not detected in the automated testing, a deduction may be given at this point. The scores assigned by the autograder will not be changed during manual review unless I discover some kind of intentional wrongdoing (such as an attempt to circumvent or reverse-engineer the autograder's operation).