examples/adventure/
A text adventure that follows a script of player commands.
- 454 lines
- 3 modules
- command line
- reads a file
The code describes the game world as data. Each turn takes the current game state and returns the new state and text to print. The program finishes without calling exit, so the test suite can check for memory the program failed to release.
Run it
A gate of black iron, standing open. A path leads north.
A walled yard, gone to grass. Doors east and west, the gate south.
There is a lantern here.
You are carrying nothing.
Cold ovens and a long table. Something glints under it.
There is a key here.
You take the key.
You are carrying: key.
A walled yard, gone to grass. Doors east and west, the gate south.
There is a lantern here.
Shelves to the ceiling, all of them empty. A small door north.
A round room with one locked door in the floor.
The key turns. The door swings up on a stair going down.
7 moves, and the stair is open.
exit 0 · 15 lines
The program
# A text adventure, played from a script.
#
# heroes run examples/adventure/main.hero -- examples/adventure/walkthrough.txt
#
# main.hero the program: the script, the transcript, and no `exit`
# game.hero one turn — a state in, a state and some lines out
# world.hero the map: rooms, the ways between them, and what is lying about
#
# **This is the corpus program that does not call `exit`** (author
# instruction), and it is here on purpose. `exit` bypasses
# `hero_runtime_check_leaks()` by design — a program asking to stop now is not
# asking for an audit, and running the gate there would report a leak for every
# live value in every frame the exit unwinds past — but on Darwin arm64 that
# counter is the **only** leak instrument there is. So the corpus keeps a path
# that runs off the end of `main` and crosses the gate, and this is it: every
# refusal below is a printed line and a return, never a status.
#
# The commands come from a file rather than from a terminal, which is what makes
# a game a corpus program at all. `game.play_all` is the same call the tests
# make, so what is tested is what runs.
use game
use world
function main()
given = args()
if len(given) == 0
print("usage: adventure <walkthrough.txt>")
return
match read_file(given[0])
.err e => print("cannot read ", given[0], ": ", e.code)
.ok script => run(script)
function run(script: str)
w = world.make()
lines = commands(script)
turn = game.play_all(w, lines)
for said in turn.said
print(said)
print("")
print(turn.next.moves, " moves, ", ending_of(turn.next))
function ending_of(g: game.Game) -> str
if g.won
return "and the stair is open."
return "and the stair is still shut."
# One command per line, with blanks and `#` comments dropped — the same shape
# every other file in this corpus uses, so a reader who has met one has met all
# of them.
function commands(script: str) -> [str]
out: [str] @= []
for line in split_lines(script)
head = trimmed(line)
if head == "" || head[0] == '#'
continue
out @ out.push(head)
return out
function is_space(c: u8) -> bool
return c == ' ' || c == '\t' || c == 13
function trimmed(line: str) -> str
from: i64 @= 0
while from < line.len() && is_space(line[from])
from @ from + 1
to: i64 @= line.len()
while to > from && is_space(line[to - 1])
to @ to - 1
return line.slice(from: from, to: to)
function split_lines(text: str) -> [str]
out: [str] @= []
start: i64 @= 0
i: i64 @= 0
while i < text.len()
if text[i] == '\n'
out @ out.push(text.slice(from: start, to: i))
start @ i + 1
i @ i + 1
if start < text.len()
out @ out.push(text.slice(from: start, to: text.len()))
return out
test "the walkthrough on disk wins the game"
script = read_file("examples/adventure/walkthrough.txt").must()
turn = game.play_all(world.make(), commands(script))
assert turn.next.won == true
assert ending_of(turn.next) == "and the stair is open."
test "comments and blank lines are not commands"
assert commands("go north\n\n# a note\n take \n") == ["go north", "take"]
assert commands("") == []
game.hero · 216 lines
# One turn: the state before, a line of English, the state after and what to say.
#
# **A turn is a value.** `play` takes a `Game` and returns a `Turn` holding the
# next `Game` and the lines to print — it changes nothing, because a language
# with no aliasing gives no way to change something you were handed. That is the
# shape a state machine takes here, and it is the reason the whole game is
# testable without a terminal: a test plays a list of commands and looks at what
# came back.
use world
record Game
here: str
carrying: [str]
taken: {str: bool}
moves: i64
won: bool
record Turn
said: [str]
next: Game
function begin(w: world.World) -> Game
return Game(here: w.start, carrying: [], taken: {}, moves: 0, won: false)
function is_space(c: u8) -> bool
return c == ' ' || c == '\t' || c == 13
function trimmed(line: str) -> str
from: i64 @= 0
while from < line.len() && is_space(line[from])
from @ from + 1
to: i64 @= line.len()
while to > from && is_space(line[to - 1])
to @ to - 1
return line.slice(from: from, to: to)
function first_word(line: str) -> str
i: i64 @= 0
while i < line.len() && line[i] != ' '
i @ i + 1
return line.slice(from: 0, to: i)
function rest_after(line: str, word: str) -> str
if word.len() >= line.len()
return ""
return trimmed(line.slice(from: word.len(), to: line.len()))
function carrying(g: Game, what: str) -> bool
for one in g.carrying
if one == what
return true
return false
## The turn
function play(w: world.World, g: Game, line: str) -> Turn
command = trimmed(line)
verb = first_word(command)
if verb == "look"
return same(g, describe(w, g))
if verb == "go"
return go(w, g, rest_after(line: command, word: "go"))
if verb == "take"
return take(w, g)
if verb == "bag"
return same(g, [bag_of(g)])
if verb == "open"
return open(g)
if verb == ""
return same(g, [])
return same(g, ["I do not know how to " + verb + "."])
function same(g: Game, said: [str]) -> Turn
return Turn(said: said, next: g)
function describe(w: world.World, g: Game) -> [str]
match world.room_at(w, g.here)
.err _ => return ["You are nowhere, which should not be possible."]
.ok room =>
lines: [str] @= [room.description]
if room.item != "" && g.taken[room.item].is_err()
lines @ lines.push("There is a " + room.item + " here.")
return lines
function go(w: world.World, g: Game, direction: str) -> Turn
if direction == ""
return same(g, ["Go where?"])
match world.through(w, here: g.here, direction: direction)
.err e => return same(g, [e.msg])
.ok there =>
moved = Game(
here: there,
carrying: g.carrying,
taken: g.taken,
moves: g.moves + 1,
won: g.won
)
return Turn(said: describe(w, moved), next: moved)
function take(w: world.World, g: Game) -> Turn
match world.room_at(w, g.here)
.err _ => return same(g, ["There is nothing here."])
.ok room =>
if room.item == "" || !g.taken[room.item].is_err()
return same(g, ["There is nothing to take."])
picked = Game(
here: g.here,
carrying: g.carrying.push(room.item),
taken: with_taken(g.taken, room.item),
moves: g.moves + 1,
won: g.won
)
return Turn(said: ["You take the " + room.item + "."], next: picked)
function with_taken(taken: {str: bool}, item: str) -> {str: bool}
out: {str: bool} @= taken
out[item] @ true
return out
function bag_of(g: Game) -> str
if g.carrying.len() == 0
return "You are carrying nothing."
return "You are carrying: " + join(sort(g.carrying), ", ") + "."
# The one command that ends the game. It needs the key **and** the room, so the
# two mistakes it can catch say different things.
function open(g: Game) -> Turn
if g.here != "vault"
return same(g, ["There is nothing here to open."])
if !carrying(g, "key")
return same(g, ["The door is locked, and you have no key."])
won = Game(
here: g.here,
carrying: g.carrying,
taken: g.taken,
moves: g.moves + 1,
won: true
)
return Turn(said: ["The key turns. The door swings up on a stair going down."], next: won)
# A whole walkthrough, played in order — the shape every test below uses, and
# the shape `main` uses too, so what is tested is what runs.
function play_all(w: world.World, lines: [str]) -> Turn
g: Game @= begin(w)
said: [str] @= []
for line in lines
turn = play(w, g, line)
for one in turn.said
said @ said.push(one)
g @ turn.next
return Turn(said: said, next: g)
test "a new game starts at the start, empty-handed"
w = world.make()
g = begin(w)
assert g.here == w.start
assert g.carrying == []
assert g.moves == 0
assert g.won == false
test "going somewhere describes it, and going nowhere says which ways there are"
w = world.make()
after = play(w, begin(w), "go north")
assert after.next.here == "yard"
assert after.next.moves == 1
refused = play(w, begin(w), "go west")
assert refused.next.here == "gate"
assert refused.next.moves == 0
assert refused.said == ["there is no way west — try north"]
test "an item is taken once, and the room remembers"
w = world.make()
turn = play_all(w, ["go north", "take", "take"])
assert turn.next.carrying == ["lantern"]
assert turn.said[turn.said.len() - 1] == "There is nothing to take."
test "the vault needs the key, and says which half is missing"
w = world.make()
no_key = play_all(w, ["go north", "go west", "go north", "open"])
assert no_key.next.won == false
assert no_key.said[no_key.said.len() - 1] == "The door is locked, and you have no key."
wrong_room = play_all(w, ["open"])
assert wrong_room.said == ["There is nothing here to open."]
test "the game can be won, and the walkthrough is the proof"
w = world.make()
won = play_all(w, ["go north", "go east", "take", "go west", "go west", "go north", "open"])
assert won.next.won == true
assert won.next.carrying == ["key"]
assert won.next.moves == 7
test "a word the game does not know is answered rather than ignored"
w = world.make()
assert play(w, begin(world.make()), "dance").said == ["I do not know how to dance."]
assert play(w, begin(world.make()), " ").said == []
world.hero · 132 lines
# The map: rooms, the ways between them, and what is lying about.
#
# Written in code rather than read from a file, and that is the point of having
# it: every other program in this corpus parses its input, and a world is the
# case where the data *is* the program. A `{str: Room}` keyed by name, each room
# holding a `{str: str}` from a direction to another room's name — so the map is
# a graph with no pointers in it, which is the only kind this language has.
#
# Names are the links. An index would be shorter and a reader would then have to
# hold a numbering in their head to know where `2` goes.
## Error codes
#
# One `constant` per code, so a typo is `error[unknown_name]` here rather
# than a comparison that is quietly false forever (panel 034 R4).
constant ERR_NO_WAY: str
"no_way"
record Room
name: str
description: str
exits: {str: str}
item: str
record World
rooms: {str: Room}
start: str
function make() -> World
rooms: {str: Room} @= {}
rooms["gate"] @ Room(
name: "gate",
description: "A gate of black iron, standing open. A path leads north.",
exits: {"north": "yard"},
item: ""
)
rooms["yard"] @ Room(
name: "yard",
description: "A walled yard, gone to grass. Doors east and west, the gate south.",
exits: {"south": "gate", "east": "kitchen", "west": "library"},
item: "lantern"
)
rooms["kitchen"] @ Room(
name: "kitchen",
description: "Cold ovens and a long table. Something glints under it.",
exits: {"west": "yard"},
item: "key"
)
rooms["library"] @ Room(
name: "library",
description: "Shelves to the ceiling, all of them empty. A small door north.",
exits: {"east": "yard", "north": "vault"},
item: ""
)
rooms["vault"] @ Room(
name: "vault",
description: "A round room with one locked door in the floor.",
exits: {"south": "library"},
item: ""
)
return World(rooms: rooms, start: "gate")
function room_at(w: World, name: str) -> Room?
return w.rooms[name]
# Where `direction` leads from here — or an error naming the ways that do exist,
# which is the whole difference between a game and a guessing game.
function through(w: World, here: str, direction: str) -> str?
room = room_at(w, here)?
match room.exits[direction]
.ok there => return ok(there)
.err _ => return fail(
code: ERR_NO_WAY,
msg: "there is no way " + direction + " — try " + join(sort(keys(room.exits)), ", ")
)
test "every exit leads to a room that exists, and comes back"
w = make()
for name in sort(keys(w.rooms))
room = room_at(w, name).must()
for direction in sort(keys(room.exits))
there = room.exits[direction].must()
assert !room_at(w, there).is_err()
test "the world is connected: every room is reachable from the start"
w = make()
seen = reachable(w)
assert seen.len() == len(w.rooms)
test "a direction that is not there names the ones that are"
w = make()
assert through(w, here: "gate", direction: "north").must() == "yard"
match through(w, here: "gate", direction: "west")
.ok _ => assert false
.err e =>
assert e.code == ERR_NO_WAY
assert e.msg == "there is no way west — try north"
test "the only item worth having is behind two doors"
w = make()
assert room_at(w, "kitchen").must().item == "key"
assert room_at(w, "vault").must().item == ""
# Every room a walker can get to, by breadth-first search over the names. It is
# a test helper that lives here because it is about the map rather than about
# the game — and a world with an unreachable room is a world with a bug in it.
function reachable(w: World) -> [str]
seen: {str: bool} @= {}
seen[w.start] @ true
queue: [str] @= [w.start]
head: i64 @= 0
while head < queue.len()
here = queue[head]
head @ head + 1
match room_at(w, here)
.err _ => continue
.ok room =>
for direction in sort(keys(room.exits))
there = room.exits[direction].must()
if seen[there].is_err()
seen[there] @ true
queue @ queue.push(there)
return sort(keys(seen))
Input
# The short way through, which is the only way through.
look
go north
bag
go east
take
bag
go west
go west
go north
open
fingerprint sha256 190e1c2b5b2c · 6 files · index.json
Next: maze. All the examples. ← Previous: wordbands
Run one yourself: the four commands. Learn the language, or read the full specification in one file.