Heroes / Esempi / adventure

examples/adventure/

Un’avventura testuale eseguita con una sequenza di comandi prestabilita.

Il mondo del gioco è descritto nei dati del programma. Ogni turno riceve lo stato attuale e restituisce quello nuovo, insieme ai messaggi da mostrare. Il programma termina senza chiamare exit: i test possono così verificare anche il controllo delle perdite di memoria, eseguito alla fine di main.

Eseguilo

$ 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 righe

Il programma

# 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 righe
game.hero · 216 righe
# 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 righe
world.hero · 132 righe
# 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 righe

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 righe

impronta sha256 190e1c2b5b2c · 6 file · index.json

Successivo: maze. Tutti gli esempi. ← Precedente: wordbands

Eseguine uno: i quattro comandi. Impara il linguaggio, oppure leggi la specifica completa in un solo file.