homework.wenqian.dev
< Back to index
Tutorial 42026-08-06

Escape the Station

Three-hour Python session · dict · reading and writing files · a text adventure that remembers your run

Mission

Finish line: A seven-room space station you can walk through, with items to carry, doors that stay locked until you hold the right thing, and a save file that survives closing the program.

Teacher provides

The room data, ASCII art, gradients, screen drawing, the command loop, and the build checks.

Student builds

Every dict lookup the map needs, the whole bag, and all three file functions.

Not a TODO

No class this week. Nothing in the drawing code or the command parser is homework.

Three-Hour Route

00:00-00:10

Open: list or dict

Ask one question only: how do you find the thing you want? By position, or by name?

00:10-00:45

Warm-Up A: six dict drills

Lookup, safe lookup, looping, dict inside dict, counting, deleting.

00:45-00:58

Play the finished game

Escape once yourself. Then point at the screen and name which part is a dict.

00:58-01:40

TODO 1-4: the map

Four small lookups. Run the checkpoint after each one before moving on.

01:40-01:50

Break

Leave the last passing checkpoint on screen.

01:50-02:15

TODO 5-8: the bag

The bag is a dict of counts. Warm-Up 6 was the rehearsal for TODO 7.

02:15-02:35

Files: r, w, a

Teach the three modes, then run Warm-Up B. Show a real file in the editor.

02:35-02:55

TODO 9-11: the save file

Save, load, append. Then quit the program and continue the same run.

02:55-03:00

Escape and one upgrade

Clear the station, then pick one upgrade and say what data it needs first.

New Idea 1 — dict

One sentence: A list finds things by position. A dict finds things by name.
python
# A list: you have to know the position.crew = ["ana", "bo", "cy"]print(crew[1])              # bo# A dict: you use the name instead.deck = {"ana": 2, "bo": 1, "cy": 3}print(deck["bo"])           # 1deck["bo"] = 4              # "bo" already exists -> this CHANGES itdeck["dee"] = 2             # "dee" is new        -> this ADDS itdel deck["ana"]             # remove the whole pairprint("cy" in deck)         # True    <- in looks at the KEYSprint(len(deck))            # 3for name in deck:           # a loop hands you the KEY, not the value    print(name, deck[name])# bo 4# cy 3# dee 2

Side by side

Goallistdict
Make an empty onethings = []things = {}
Get one outthings[0]things["rope"]
Add onethings.append("rope")things["rope"] = 2
Change onethings[0] = "rope"things["rope"] = 5
Remove onethings.remove("rope")del things["rope"]
How manylen(things)len(things)

One line does double duty: things["rope"] = 2 adds the pair when the key is new, and changes it when the key is already there.

Three traps

KeyError

Reading a key that is not there stops the program. Check "key in dict" first.

A loop gives keys

for x in bag hands you the key, not the value. Use bag[x] to reach the value.

in checks keys

"rope" in bag asks about the keys. In a list the same words ask about the values.

Warm-Up A — dict

Try each one for two minutes before opening Hint 1. Expected output appears with the Show Solution switch at the top of the page.

Warm-Up 15 min

Numbers or names

Predict all four printed lines without running the code. Then run it and check.

python
scores = [90, 75, 88]grades = {"ana": 90, "bo": 75, "cy": 88}print(scores[1])print(grades["bo"])grades["bo"] = 80grades["dee"] = 61print(len(grades))print(grades)
Hint 1
scores[1] uses position 1. grades["bo"] uses the name "bo".
Hint 2
One line does two jobs: an existing key is changed, a new key is added.
Warm-Up 26 min

Look up without crashing

Return the count. A name that is not in the dict must return 0 instead of crashing.

python
stock = {"apple": 3, "pear": 0}def how_many(store, name):    # TODO    passprint(how_many(stock, "apple"))print(how_many(stock, "pear"))print(how_many(stock, "plum"))
Hint 1
Writing store[name] straight away gives KeyError for "plum". Guard it first.
Hint 2
in checks the keys of a dict, never the values.
Warm-Up 35 min

Walk through a dict

Print one line per item, exactly like: bolt costs 4

python
prices = {"bolt": 4, "rope": 12, "lamp": 7}# TODO: print one line per item
Hint 1
for name in prices: gives you the KEY each time, not the value.
Hint 2
Once you hold the key, prices[name] gives the value. Numbers need str().
Warm-Up 46 min

A dict inside a dict

Predict all three lines. This is exactly the shape of today's station map.

python
crew = {    "ana": {"job": "pilot", "deck": 2},    "bo": {"job": "medic", "deck": 1},}print(crew["bo"]["job"])crew["ana"]["deck"] = 3print(crew["ana"])print("cy" in crew)
Hint 1
crew["bo"] is itself a dict. The second bracket looks inside that dict.
Hint 2
Two brackets means two lookups. Read them from the outside in.
Warm-Up 56 min

Count with a dict

Count how many times each value appears. Write the loop yourself.

python
letters = ["a", "b", "a", "c", "a", "b"]def count_all(values):    # TODO    passprint(count_all(letters))
Hint 1
Start from an empty dict: counts = {}
Hint 2
The first time you meet a value, set it to 0. Then add 1 for every value.
Warm-Up 66 min

Delete a key

Use one item: lower the count by 1, and remove the key completely once it hits 0.

python
bag = {"rope": 2, "lamp": 1}def use_one(store, name):    # TODO    passprint(use_one(bag, "lamp"))print(bag)print(use_one(bag, "torch"))
Hint 1
If the name is not a key at all, return False immediately.
Hint 2
Subtract first, then check whether the count is now 0, then del.

New Idea 2 — files

One sentence: Everything in a variable disappears when the program stops. A file is the only part that stays.
python
# "w" creates the file, and empties it if it was already there.with open("crew.txt", "w") as crew_file:    crew_file.write("ana 2\n")      # write() never adds the line break for you    crew_file.write("bo 1\n")# "a" keeps everything already inside and writes at the end.with open("crew.txt", "a") as crew_file:    crew_file.write("cy 3\n")# "r" reads it back. One line at a time is usually easiest.with open("crew.txt", "r") as crew_file:    for line in crew_file:        parts = line.split()        # "ana 2\n" -> ["ana", "2"]        print(parts[0], "is on deck", int(parts[1]))# ana is on deck 2# bo is on deck 1# cy is on deck 3

The three modes

modeFile is missingFile already existsUsed for
"r"FileNotFoundErrorOpens itRead
"w"Creates itEmpties it immediatelyWrite
"a"Creates itKeeps it, writes at the endAppend
The dangerous one is "w". It empties the file the moment open() runs, before you have written a single character. Use "a" whenever the old content matters.

Five rules

  1. 01Always use with open(...) as f:. The file closes by itself when the indent ends.
  2. 02write() adds nothing on its own. You type the line break yourself.
  3. 03read() gives the whole file as one string. for line in f: gives one line at a time.
  4. 04Everything read out of a file is text. Numbers need int() before you can add them.
  5. 05A file may not exist yet. Wrap the read in try / except FileNotFoundError.

Warm-Up B — files

Run these in a folder you can open. After every exercise, look at the real file in the editor before moving on.

Warm-Up 75 min

Write a file, then read it back

Run it. Then open notes.txt in your editor and confirm it really exists on disk.

python
with open("notes.txt", "w") as note_file:    note_file.write("line one\n")    note_file.write("line two\n")with open("notes.txt", "r") as note_file:    print(note_file.read())
Hint 1
write() adds nothing on its own. Without \n everything lands on one line.
Hint 2
The output ends with a blank line: read() kept the last \n and print added one more.
Warm-Up 86 min

"w" erases, "a" continues

Predict the output. Then answer this: if you run the WHOLE file a second time, does the output change?

python
with open("diary.txt", "w") as diary:    diary.write("day\n")with open("diary.txt", "a") as diary:    diary.write("night\n")with open("diary.txt", "r") as diary:    print(diary.read())
Hint 1
"w" empties the file at the moment open() runs, before you write anything.
Hint 2
Running it twice gives the same two lines. The first "w" wipes it again.
Warm-Up 96 min

Read lines back into a dict

Turn each line into one dict pair. This is exactly what load_game does later.

python
with open("stock.txt", "w") as stock_file:    stock_file.write("rope 12\n")    stock_file.write("lamp 7\n")result = {}with open("stock.txt", "r") as stock_file:    for line in stock_file:        parts = line.split()        # TODO: put parts[0] -> int(parts[1]) into resultprint(result)
Hint 1
"rope 12\n".split() gives ["rope", "12"]. split() also drops the \n.
Hint 2
Everything read out of a file is a string. "12" is not 12 until int() runs.
Warm-Up 105 min

When the file is missing

Return the text of the file, or None when there is no such file. Do not let it crash.

python
with open("stock.txt", "w") as stock_file:    stock_file.write("rope 12\n")def read_or_none(path):    # TODO    passprint(read_or_none("stock.txt"))print(read_or_none("ghost.txt"))
Hint 1
Opening a missing file with "r" raises FileNotFoundError.
Hint 2
Put the normal read inside try:, and return None inside except FileNotFoundError:.

Play It Before Building It

Run the finished version and escape once. Then point at the screen and say which part of it is stored in a dict.

terminal
+--------------------------------------------------------------------------------+| STORAGE BAY   moves 2                                                          ||                                                                                ||                          .------.  .------.  .------.                          ||                          |######|  |######|  |######|                          ||                          |######|  |######|  |######|                          ||                          '------'  '------'  '------'                          ||                          .------.  .------.                                    ||                          |######|  |######|                                    ||                          '------'  '------'                                    ||                                                                                || Crates everywhere. Something small rolls across the floor when the hull shakes. ||                                                                                || Exits: east, north                                                             || You see: flashlight                                                            || Bag: (empty)                                                                   ||                                                                                || > Power Core is sealed. You need the flashlight.                               ||                                                                                || n s e w | look | take <item> | bag | x <item> | save | load | help | quit      |+--------------------------------------------------------------------------------+

The map is one dict

terminal
   +---------------+          +---------------+          +---------------+   |   Power Core  |          |     Bridge    |----------|  Docking Bay  |   +-------+-------+          +-------+-------+          +---------------+    needs flashlight                  |                    GOAL: keycard           |                          |   +-------+-------+          +-------+-------+          +---------------+   |  Storage Bay  |----------|    Corridor   |----------|  Research Lab |   +---------------+          +-------+-------+          +---------------+                                      |                              +-------+-------+                              |    Cryo Pod   |  START                              +---------------+   Along any line: e moves right, w moves left, n moves up, s moves down.   ROOMS["storage"]["exits"]  ->  {"east": "corridor", "north": "power"}   ROOMS["storage"]["items"]  ->  ["flashlight"]

Looked up by name

The room you are in, where each direction leads, what an item does, and how many of it you carry.

Written to disk

station_save.txt holds the current run. station_log.txt keeps the history of every run.

TODO Contract

Read the card before the code. Every function states its purpose, input, return value, state change, and smallest useful test. Complete one at a time and run its checkpoint.

TODO 1Phase 1 - The map

room_name

Purpose

Read one value out of the big ROOMS dict.

Input

room_id: str, for example "lab"

Output / Return

That room's "name" value, or "Unknown Room" when the id is not a key.

State change

Nothing.

Checkpoint

python
print(room_name("lab"))      # Research Labprint(room_name("attic"))    # Unknown Room
TODO 2Phase 1 - The map

exits_of

Purpose

Reach the dict that is stored INSIDE a room.

Input

room_id: str, for example "corridor"

Output / Return

That room's "exits" dict, or an empty dict when the room does not exist.

State change

Nothing.

Checkpoint

python
print(exits_of("pod"))       # {'north': 'corridor'}print(exits_of("attic"))     # {}
TODO 3Phase 1 - The map

next_room

Purpose

Answer the only question walking needs: where does this direction lead?

Input

room_id: str, direction: str such as "east"

Output / Return

The room id you arrive at, or None when there is no exit that way.

State change

Nothing. Reuse exits_of instead of writing the lookup twice.

Checkpoint

python
print(next_room("corridor", "east"))   # labprint(next_room("pod", "east"))        # None
TODO 4Phase 1 - The map

exit_list

Purpose

Collect the direction names so the screen can show them.

Input

room_id: str

Output / Return

A sorted list of the direction names.

State change

Nothing. Looping over a dict gives you its keys.

Checkpoint

python
print(exit_list("corridor"))   # ['east', 'north', 'south', 'west']print(exit_list("attic"))      # []
TODO 5Phase 2 - The bag

add_item

Purpose

Put one item in the bag. The bag counts items instead of listing them.

Input

bag: dict, item: str such as "keycard"

Output / Return

No return value.

State change

Raise that item's count by 1. A brand new item ends up at 1.

Checkpoint

python
bag = {}add_item(bag, "bolt")add_item(bag, "bolt")print(bag)     # {'bolt': 2}
TODO 6Phase 2 - The bag

has_item

Purpose

Answer one yes-or-no question. Locked doors use this.

Input

bag: dict, item: str

Output / Return

True when the bag holds at least one, otherwise False.

State change

Nothing. Return True or False, not a number.

Checkpoint

python
print(has_item({"bolt": 1}, "bolt"))   # Trueprint(has_item({}, "bolt"))            # False
TODO 7Phase 2 - The bag

remove_item

Purpose

Use one item up, and keep the bag tidy afterwards.

Input

bag: dict, item: str

Output / Return

True when one was removed, otherwise False.

State change

Lower the count by 1, and delete the key once the count reaches 0.

Checkpoint

python
bag = {"bolt": 1}print(remove_item(bag, "bolt"))   # Trueprint(bag)                        # {}print(remove_item(bag, "bolt"))   # False
TODO 8Phase 2 - The bag

bag_lines

Purpose

Turn the bag into text the screen can print.

Input

bag: dict

Output / Return

A sorted list of strings like "rope x2". An empty bag gives ["(empty)"].

State change

Nothing.

Checkpoint

python
print(bag_lines({"rope": 2, "bolt": 1}))   # ['bolt x1', 'rope x2']print(bag_lines({}))                       # ['(empty)']
TODO 9Phase 3 - The save file

save_game

Purpose

Write the whole run to disk so it survives closing the program.

Input

path: str, state: dict with the keys "room", "moves", "bag"

Output / Return

No return value.

State change

Open with "w" and write one record per line. Add \n yourself.

Checkpoint

python
save_game("test_save.txt", {"room": "bridge", "moves": 7, "bag": {"keycard": 1}})with open("test_save.txt", "r") as check:    print(check.read())# room bridge# moves 7# item keycard 1
TODO 10Phase 3 - The save file

load_game

Purpose

Rebuild the state dict from the text file.

Input

path: str

Output / Return

A state dict, or None when the file does not exist.

State change

Split every line, and turn number text back into int.

Checkpoint

python
print(load_game("test_save.txt"))# {'room': 'bridge', 'moves': 7, 'bag': {'keycard': 1}}print(load_game("no_such_file.txt"))# None
TODO 11Phase 3 - The save file

append_log

Purpose

Keep a history that never erases what came before.

Input

path: str, message: str

Output / Return

No return value.

State change

Add exactly one line at the end. "w" would destroy the history, so use "a".

Checkpoint

python
append_log("test_log.txt", "one")append_log("test_log.txt", "two")with open("test_log.txt", "r") as check:    print(check.read())# one# two

Student Work Area

Only edit this part: All eleven TODOs live here. You never need to read the drawing code or the command loop to finish them.

student_work.py

python
# ================================================================# PART 1 - THE MAP# ROOMS is a dict. Every value inside it is another dict.# ================================================================def room_name(room_id):    # TODO 1    # Input: room_id, a string such as "lab"    # Return: that room's "name" value; "Unknown Room" if room_id is not a key of ROOMS    # Change: nothing    return ""def exits_of(room_id):    # TODO 2    # Input: room_id, a string such as "corridor"    # Return: that room's "exits" dict; an empty dict if the room does not exist    # Change: nothing    return {}def next_room(room_id, direction):    # TODO 3    # Input: room_id such as "corridor", direction such as "east"    # Return: the room id you arrive at, or None when that direction has no exit    # Change: nothing    return Nonedef exit_list(room_id):    # TODO 4    # Input: room_id, a string    # Return: a sorted list of the direction names, for example ["east", "north"]    # Change: nothing    return []# ================================================================# PART 2 - THE BAG# The bag is a dict: item name -> how many you carry.# {"flashlight": 1, "battery": 2}# ================================================================def add_item(bag, item):    # TODO 5    # Input: bag, a dict; item, a string such as "keycard"    # Return: nothing    # Change: raise that item's count by 1, starting from 0 when it is new    passdef has_item(bag, item):    # TODO 6    # Input: bag, a dict; item, a string    # Return: True when the bag holds at least one of that item, otherwise False    # Change: nothing    return Falsedef remove_item(bag, item):    # TODO 7    # Input: bag, a dict; item, a string    # Return: True when one was removed, otherwise False    # Change: lower that item's count by 1; delete the key once the count reaches 0    return Falsedef bag_lines(bag):    # TODO 8    # Input: bag, a dict    # Return: a sorted list of strings like "flashlight x1"    #         an empty bag returns ["(empty)"]    # Change: nothing    return []# ================================================================# PART 3 - THE SAVE FILE# The save file is plain text. One record per line:#     room bridge#     moves 17#     item flashlight 1# ================================================================def save_game(path, state):    # TODO 9    # Input: path, a file name; state, a dict with keys "room", "moves", "bag"    # Return: nothing    # Change: write the file in the format shown above, one record per line    passdef load_game(path):    # TODO 10    # Input: path, a file name    # Return: a state dict {"room": ..., "moves": ..., "bag": ...},    #         or None when the file does not exist    # Change: nothing    return Nonedef append_log(path, message):    # TODO 11    # Input: path, a file name; message, a string    # Return: nothing    # Change: add one more line to the end of the file, keeping every older line    pass

Project Template

Copy this whole file once so the game can run. During class, work in the Student Work Area above and search by TODO number.

escape_station.py

python
import osimport reimport sysfrom time import sleepESC = chr(27) + "["RESET = f"{ESC}0m"USE_COLOR = sys.stdout.isatty()ANIMATE = sys.stdout.isatty()ANSI_RE = re.compile(re.escape(ESC) + r"[0-9;]*m")SCREEN_WIDTH = 82SAVE_PATH = "station_save.txt"LOG_PATH = "station_log.txt"STYLES = {    "title": "1;96",    "room": "1;95",    "item": "1;93",    "good": "92",    "bad": "91",    "dim": "2",    "hint": "96",}def paint(text, style_code):    if not USE_COLOR:        return str(text)    return f"{ESC}{style_code}m{text}{RESET}"def style(text, name):    return paint(text, STYLES[name])def rgb(text, red, green, blue):    if not USE_COLOR:        return str(text)    return f"{ESC}38;2;{red};{green};{blue}m{text}{RESET}"def gradient_text(text, start=(90, 220, 255), end=(200, 110, 255)):    if not USE_COLOR or len(text) <= 1:        return str(text)    result = ""    steps = len(text) - 1    for index, character in enumerate(text):        ratio = index / steps        red = round(start[0] + (end[0] - start[0]) * ratio)        green = round(start[1] + (end[1] - start[1]) * ratio)        blue = round(start[2] + (end[2] - start[2]) * ratio)        result += rgb(character, red, green, blue)    return resultdef visible_len(text):    return len(ANSI_RE.sub("", str(text)))def pad_visible(text, width):    return str(text) + " " * max(0, width - visible_len(text))def center_visible(text, width):    spaces = max(0, width - visible_len(text))    left = spaces // 2    return " " * left + str(text) + " " * (spaces - left)def clear_screen():    if sys.stdout.isatty():        print(f"{ESC}2J{ESC}H", end="")def print_panel(title, lines, width=SCREEN_WIDTH):    inside = width - 4    print("+" + "-" * (width - 2) + "+")    print("| " + center_visible(title, inside) + " |")    print("+" + "-" * (width - 2) + "+")    for line in lines:        print("| " + pad_visible(line, inside) + " |")    print("+" + "-" * (width - 2) + "+")def type_line(text, delay=0.012):    if not ANIMATE:        print(text)        return    for character in text:        print(character, end="", flush=True)        sleep(delay)    print()def animate_scan(label):    if not ANIMATE:        return    for filled in range(0, 25, 4):        scan = "[" + "=" * filled + "." * (24 - filled) + "]"        line = center_visible(f"{label}  {scan}", SCREEN_WIDTH - 2)        print("\r" + gradient_text(line, (80, 255, 200), (200, 110, 255)), end="", flush=True)        sleep(0.05)    print("\r" + " " * (SCREEN_WIDTH - 2) + "\r", end="", flush=True)ROOMS = {    "pod": {        "name": "Cryo Pod",        "description": "Frost slides off the glass. One amber lamp blinks above your head.",        "exits": {"north": "corridor"},        "items": [],        "art": [            "+----------------------+",            "|   .-.          .-.   |",            "|   '-'          '-'   |",            "|                      |",            "|     [==========]     |",            "|                      |",            "+----------------------+",        ],    },    "corridor": {        "name": "Main Corridor",        "description": "A long hallway. Emergency strips glow along the floor in both directions.",        "exits": {"south": "pod", "east": "lab", "west": "storage", "north": "bridge"},        "items": [],        "art": [            " ____________________",            "|  ||  ||  ||  ||  ||",            "|  ||  ||  ||  ||  ||",            "|__||__||__||__||__||",            " .  .  .  .  .  .  .",        ],    },    "lab": {        "name": "Research Lab",        "description": "Broken sample jars. A cracked terminal still shows a slow star chart.",        "exits": {"west": "corridor"},        "items": ["notebook"],        "art": [            ".--------.    .--------.",            "| o  .  o|    |  .--.  |",            "| .  o  .|    | ( oo ) |",            "'--------'    |  '--'  |",            "  |    |      '--------'",            "__|____|________________",        ],    },    "storage": {        "name": "Storage Bay",        "description": "Crates everywhere. Something small rolls across the floor when the hull shakes.",        "exits": {"east": "corridor", "north": "power"},        "items": ["flashlight"],        "art": [            ".------.  .------.  .------.",            "|######|  |######|  |######|",            "|######|  |######|  |######|",            "'------'  '------'  '------'",            ".------.  .------.",            "|######|  |######|",            "'------'  '------'",        ],    },    "power": {        "name": "Power Core",        "description": "Pitch black without a light. Far below, the reactor is still turning.",        "exits": {"south": "storage"},        "items": ["keycard"],        "needs": "flashlight",        "art": [            "     .-''''''''''-.     ",            "   .'  .--------.  '.   ",            "  /   |  /\\  /\\  |   \\  ",            " |    |  \\ \\/ /  |    | ",            "  \\   |   \\  /   |   /  ",            "   '.  '--------'  .'   ",            "     '-..........-'     ",        ],    },    "bridge": {        "name": "Bridge",        "description": "Wide windows. Outside, the Starfall wreck drifts in slow circles.",        "exits": {"south": "corridor", "east": "dock"},        "items": [],        "art": [            " ________________________ ",            "/    .    *       .    *  \\",            "|  *     .     *      .   |",            "|    .       *     *      |",            "\\__    *      .     .   __/",            "   |__|    |__|    |__|    ",        ],    },    "dock": {        "name": "Docking Bay",        "description": "One escape pod is still attached. The hatch light turns green as you enter.",        "exits": {"west": "bridge"},        "items": [],        "needs": "keycard",        "art": [            "      .------------.      ",            "   .-'              '-.   ",            "  /    .----------.    \\  ",            " |     |    ( )   |     | ",            "  \\    '----------'    /  ",            "   '-.              .-'   ",            "       |  |    |  |       ",        ],    },}ITEM_INFO = {    "flashlight": "A heavy work light. Bright enough to walk into a dark room.",    "keycard": "Crew keycard, level 3. Sealed doors accept it.",    "notebook": "A crew notebook. The last page reads: the light is stored west of the corridor.",}START_ROOM = "pod"GOAL_ROOM = "dock"# ================================================================# PART 1 - THE MAP# ROOMS is a dict. Every value inside it is another dict.# ================================================================def room_name(room_id):    # TODO 1    # Input: room_id, a string such as "lab"    # Return: that room's "name" value; "Unknown Room" if room_id is not a key of ROOMS    # Change: nothing    return ""def exits_of(room_id):    # TODO 2    # Input: room_id, a string such as "corridor"    # Return: that room's "exits" dict; an empty dict if the room does not exist    # Change: nothing    return {}def next_room(room_id, direction):    # TODO 3    # Input: room_id such as "corridor", direction such as "east"    # Return: the room id you arrive at, or None when that direction has no exit    # Change: nothing    return Nonedef exit_list(room_id):    # TODO 4    # Input: room_id, a string    # Return: a sorted list of the direction names, for example ["east", "north"]    # Change: nothing    return []# ================================================================# PART 2 - THE BAG# The bag is a dict: item name -> how many you carry.# {"flashlight": 1, "battery": 2}# ================================================================def add_item(bag, item):    # TODO 5    # Input: bag, a dict; item, a string such as "keycard"    # Return: nothing    # Change: raise that item's count by 1, starting from 0 when it is new    passdef has_item(bag, item):    # TODO 6    # Input: bag, a dict; item, a string    # Return: True when the bag holds at least one of that item, otherwise False    # Change: nothing    return Falsedef remove_item(bag, item):    # TODO 7    # Input: bag, a dict; item, a string    # Return: True when one was removed, otherwise False    # Change: lower that item's count by 1; delete the key once the count reaches 0    return Falsedef bag_lines(bag):    # TODO 8    # Input: bag, a dict    # Return: a sorted list of strings like "flashlight x1"    #         an empty bag returns ["(empty)"]    # Change: nothing    return []# ================================================================# PART 3 - THE SAVE FILE# The save file is plain text. One record per line:#     room bridge#     moves 17#     item flashlight 1# ================================================================def save_game(path, state):    # TODO 9    # Input: path, a file name; state, a dict with keys "room", "moves", "bag"    # Return: nothing    # Change: write the file in the format shown above, one record per line    passdef load_game(path):    # TODO 10    # Input: path, a file name    # Return: a state dict {"room": ..., "moves": ..., "bag": ...},    #         or None when the file does not exist    # Change: nothing    return Nonedef append_log(path, message):    # TODO 11    # Input: path, a file name; message, a string    # Return: nothing    # Change: add one more line to the end of the file, keeping every older line    passDIRECTION_WORDS = {    "n": "north",    "north": "north",    "s": "south",    "south": "south",    "e": "east",    "east": "east",    "w": "west",    "west": "west",}HELP_LINES = [    "n / s / e / w      walk in that direction",    "look               describe this room again",    "take <item>        pick something up",    "bag                list what you carry",    "x <item>           inspect an item you carry",    "save / load        write or read station_save.txt",    "help / quit",]def new_state():    return {"room": START_ROOM, "moves": 0, "bag": {}}def items_here(state):    visible = []    for item in ROOMS[state["room"]]["items"]:        if not has_item(state["bag"], item):            visible.append(item)    return visibledef render(state, messages):    clear_screen()    room = ROOMS[state["room"]]    inside = SCREEN_WIDTH - 4    header = style(room_name(state["room"]).upper(), "room")    counter = style("moves " + str(state["moves"]), "dim")    lines = [header + "   " + counter, ""]    art_width = 0    for art_line in room["art"]:        art_width = max(art_width, len(art_line))    for art_line in room["art"]:        padded = art_line + " " * (art_width - len(art_line))        lines.append(center_visible(gradient_text(padded), inside))    lines.append("")    lines.append(room["description"])    lines.append("")    exits = exit_list(state["room"])    if len(exits) == 0:        lines.append(style("Exits: none", "dim"))    else:        lines.append("Exits: " + style(", ".join(exits), "hint"))    here = items_here(state)    if len(here) > 0:        lines.append("You see: " + style(", ".join(here), "item"))    lines.append("Bag: " + ", ".join(bag_lines(state["bag"])))    lines.append("")    for message in messages:        lines.append("> " + message)    lines.append("")    lines.append(style("n s e w | look | take <item> | bag | x <item> | save | load | help | quit", "dim"))    print_panel(gradient_text("ESCAPE THE STATION"), lines)def try_move(state, direction):    destination = next_room(state["room"], direction)    if destination is None:        return "There is no way " + direction + " from here."    needed = ROOMS[destination].get("needs")    if needed is not None and not has_item(state["bag"], needed):        return room_name(destination) + " is sealed. You need the " + needed + "."    animate_scan("MOVING " + direction.upper())    state["room"] = destination    state["moves"] = state["moves"] + 1    return "You walk " + direction + " into the " + room_name(destination) + "."def try_take(state, item):    if item == "":        return "Take what? Try: take flashlight"    if item not in items_here(state):        return "There is no " + item + " here."    add_item(state["bag"], item)    append_log(LOG_PATH, "picked up " + item + " in " + room_name(state["room"]))    return "You take the " + item + "."def try_inspect(state, item):    if item == "":        return "Inspect what? Try: x notebook"    if not has_item(state["bag"], item):        return "You are not carrying a " + item + "."    if item not in ITEM_INFO:        return "You turn it over and learn nothing."    return ITEM_INFO[item]def handle_command(state, raw):    parts = raw.strip().lower().split()    if len(parts) == 0:        return "Type a command. Type help to see the list.", False    word = parts[0]    rest = " ".join(parts[1:])    if word in DIRECTION_WORDS:        return try_move(state, DIRECTION_WORDS[word]), False    if word == "go":        if rest in DIRECTION_WORDS:            return try_move(state, DIRECTION_WORDS[rest]), False        return "Go where? Try: go north", False    if word == "look":        return ROOMS[state["room"]]["description"], False    if word in ("take", "t", "get"):        return try_take(state, rest), False    if word in ("bag", "i", "inventory"):        return "You carry: " + ", ".join(bag_lines(state["bag"])), False    if word in ("x", "inspect", "read"):        return try_inspect(state, rest), False    if word == "save":        save_game(SAVE_PATH, state)        append_log(LOG_PATH, "saved at " + room_name(state["room"]))        return style("Progress written to " + SAVE_PATH + ".", "good"), False    if word == "load":        loaded = load_game(SAVE_PATH)        if loaded is None:            return style("No save file yet. Use save first.", "bad"), False        state["room"] = loaded["room"]        state["moves"] = loaded["moves"]        state["bag"] = loaded["bag"]        return style("Save file restored.", "good"), False    if word in ("help", "h", "?"):        return " | ".join(["n s e w", "look", "take", "bag", "x", "save", "load", "quit"]), False    if word in ("quit", "q", "exit"):        return "", True    return "Unknown command: " + word + ". Type help.", Falsedef intro():    clear_screen()    print_panel(        gradient_text("ESCAPE THE STATION"),        [            "",            center_visible("The Starfall station lost power eleven hours ago.", SCREEN_WIDTH - 4),            center_visible("You are the only pod that thawed.", SCREEN_WIDTH - 4),            "",            center_visible(style("Reach the Docking Bay to escape.", "hint"), SCREEN_WIDTH - 4),            "",        ]        + HELP_LINES,    )    type_line("")    type_line("  Booting life support ...")    animate_scan("LIFE SUPPORT")def show_result(state, escaped):    clear_screen()    if escaped:        heading = style("ESCAPE SUCCESSFUL", "good")        message = "The pod detaches. The station falls away behind you."    else:        heading = style("RUN ENDED", "bad")        message = "You stayed aboard. The station is still dark."    print_panel(        heading,        [            message,            "",            "Rooms reached: " + room_name(state["room"]),            "Moves used: " + str(state["moves"]),            "Carried out: " + ", ".join(bag_lines(state["bag"])),        ],    )def play(state):    messages = ["You wake up. The pod hatch is already open."]    while True:        render(state, messages)        if state["room"] == GOAL_ROOM:            append_log(LOG_PATH, "escaped in " + str(state["moves"]) + " moves")            return True        try:            raw = input("Command: ")        except EOFError:            return False        message, should_quit = handle_command(state, raw)        if should_quit:            return False        messages = [message]def run_build_checks():    assert room_name("lab") == "Research Lab", (        "TODO 1 failed: room_name should read the 'name' value out of ROOMS."    )    assert room_name("nowhere") == "Unknown Room", (        "TODO 1 failed: an id that is not a key must give 'Unknown Room'."    )    assert exits_of("pod") == {"north": "corridor"}, (        "TODO 2 failed: exits_of should return the inner 'exits' dict."    )    assert exits_of("nowhere") == {}, (        "TODO 2 failed: a missing room must give an empty dict."    )    assert next_room("corridor", "east") == "lab", (        "TODO 3 failed: look the direction up inside the exits dict."    )    assert next_room("pod", "east") is None, (        "TODO 3 failed: a direction with no exit must give None."    )    assert exit_list("corridor") == ["east", "north", "south", "west"], (        "TODO 4 failed: collect the keys of the exits dict and sort them."    )    assert exit_list("nowhere") == [], (        "TODO 4 failed: a missing room must give an empty list."    )    bag = {}    add_item(bag, "bolt")    add_item(bag, "bolt")    add_item(bag, "rope")    assert bag == {"bolt": 2, "rope": 1}, (        "TODO 5 failed: a new item starts at 1, a repeat item counts up."    )    assert has_item(bag, "bolt") is True, (        "TODO 6 failed: has_item must return True or False, not a number."    )    assert has_item(bag, "torch") is False, (        "TODO 6 failed: an item that is not a key must give False."    )    assert remove_item(bag, "bolt") is True and bag["bolt"] == 1, (        "TODO 7 failed: removing one should lower the count by 1."    )    assert remove_item(bag, "bolt") is True and "bolt" not in bag, (        "TODO 7 failed: the key must be deleted once the count reaches 0."    )    assert remove_item(bag, "bolt") is False, (        "TODO 7 failed: removing something you do not carry must give False."    )    assert bag_lines({}) == ["(empty)"], (        "TODO 8 failed: an empty bag must give ['(empty)']."    )    assert bag_lines({"rope": 2, "bolt": 1}) == ["bolt x1", "rope x2"], (        "TODO 8 failed: build 'name xcount' strings in sorted order."    )    check_save = "station_check.txt"    check_log = "station_check_log.txt"    for path in (check_save, check_log):        if os.path.exists(path):            os.remove(path)    assert load_game(check_save) is None, (        "TODO 10 failed: a file that does not exist must give None."    )    sample = {"room": "bridge", "moves": 7, "bag": {"keycard": 1, "bolt": 2}}    save_game(check_save, sample)    assert load_game(check_save) == sample, (        "TODO 9 or 10 failed: what you saved must load back exactly."    )    append_log(check_log, "one")    append_log(check_log, "two")    with open(check_log, "r") as check_file:        assert check_file.read() == "one\ntwo\n", (            "TODO 11 failed: append mode must keep the older lines."        )    for path in (check_save, check_log):        os.remove(path)def main():    try:        run_build_checks()    except (AssertionError, AttributeError, TypeError, KeyError, IndexError) as error:        detail = str(error) if str(error) else "A required result was incorrect."        print_panel(            "BUILD CHECK FAILED",            [                "Finish TODO 1-11 before the station will boot.",                "",                detail,                "",                "Fix one TODO, then run the file again.",            ],        )        return    intro()    state = new_state()    saved = load_game(SAVE_PATH)    if saved is not None:        answer = input("A save file was found. Continue it? [y/N]: ").strip().lower()        if answer in ("y", "yes"):            state = saved    escaped = play(state)    show_result(state, escaped)if __name__ == "__main__":    main()
Build rule: The station only boots after all eleven build checks pass. A failed check is not a crash: it names the one TODO that is not finished yet.

Run Checklist

Start the game

bash
python3 escape_station.py# The build check runs first. Only after TODO 1-11 pass does the station boot.# Useful commands during play:# n s e w      walk# look         describe this room again# take X       pick something up# bag          list what you carry# x X          inspect an item you carry# save / load  station_save.txt# help / quit
Run it in a real terminal to see the colour gradients and the moving scan line. A captured output or some IDE consoles fall back to plain text automatically.
01Walk north out of the Cryo Pod. The corridor must list four exits.
02Try to enter the Power Core with no flashlight. It must refuse instead of crashing.
03Take the notebook in the lab, then type x notebook. It tells you where the light is.
04Take the flashlight in the Storage Bay, then walk north into the Power Core.
05Take the keycard, return to the Bridge, and open the Docking Bay to escape.
06Type save mid-run, quit, start again, answer y. Same room, same bag, same move count.
07Type a word the game does not know. It must say so instead of stopping.
08Open station_log.txt. Every pick-up and every save is listed, oldest first.

Optional Upgrades

Choose exactly one. Before writing any code, say out loud which dict you are about to change.

A map command

Print every room name and its exits by looping over ROOMS once.

A drop command

Put one item back. remove_item already does the hard part.

One more room

Add a dict entry, and remember to add the exit on BOTH sides of the door.

A door needing two items

Change "needs" from one string into a list, then check every item in it.

Teacher Checkpoints

After TODO 2: ask what type ROOMS["pod"] is, and what type ROOMS["pod"]["exits"] is. Both are dicts.

After TODO 4: ask why we sort. Without sorted() the screen order depends on how the dict was typed.

After TODO 7: ask what the bag prints when the key is deleted, and when a 0 is left behind.

After TODO 9: open station_save.txt in the editor, change moves by hand, then load it in the game.

At the end: the map is a dict and the bag is a dict. Ask the student to say what each one looks up.