Heroes / Examples / adventure

examples/adventure/

A text adventure that follows a script of player commands.

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

$ heroes run examples/adventure/main.hero -- examples/adventure/walkthrough.txt
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("") == []
examples/adventure/main.hero, 106 lines
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 == []
examples/adventure/game.hero, 216 lines
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))
examples/adventure/world.hero, 132 lines

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
examples/adventure/walkthrough.txt, 11 lines

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.