GitHub Repo

I think the Covid jab hit me this time. Just a short article. Anyway, it’s Saturday, what do you expect? (We do stumble on a nice result.)

The list of things to consider doing, from yesterday, is:

  1. I’d like to work on room definition and connection to see if we can make that easier and more compact.
  2. There are weird things in the tests, like the one that makes XYZZY into a direction like East. Those things should be cleaned up.
  3. We should devise a few more puzzles, some perhaps requiring global information, and solve them. That should drive out some improvements somewhere.
  4. I’d really really like to be able to deploy this in some easy way. If that were possible, I’d code up a real game to play.
  5. I’m sure there are places in the code, especially in the initial setup, player area, and such, that need work. We’ll look for them.
  6. I wonder if there’s any value to publishing the program as an example, with these articles available as background, and new articles treating the finished system as an example of a game-building DSL, with write-ups describing how it all works, and enabling anyone who wanted to to build up a game of their own.

I sketched a bit of a map this morning, partly because it’s Inktober, partly because I had an idea for notation, and partly for something to do. It’s on the iPad in the other room: I’ll grab it and paste it in here soon. My drawing shows what connections between rooms might look like on a game-planning map: if R.Spring has an exit to the D.West to R.Woods, and the return is to exit R.Woods to the D.Southeast, the map shows the connection exiting R.Spring at 9 o’clock and entering R.Woods at about 4:30. That should allow for most connections to be drawn without a specific directional note.

Not pretty: Concept only:

very rough map showing direction lines

But I’m no closer to thinking of a convenient notation than I was before. Let’s muse on it.

R.Spring->D.West->R.Woods, and R.Woods->D.Southeast->R.Spring

The notation today would be to put a go into each room when we define it, and given a map, that’s not too awful:

room(R.Spring
	go(D.East, R.Woods)
	...
)

room(R.Woods
	go(D.Southeast, R.Spring)
	...
)

Most rooms have just two exits, as they tend to be chamber that you walk through, but of course some can have more. In any case, if you have a map, you can look at the room and quickly type in what’s needed. Of course, each go command has a third optional parameter, allowed, which is an anonymous function that can do whatever it needs to and then returns true if the move is to be allowed, false otherwise.

We could have another DSL command, perhaps top level, that specified both rooms and directions:

path(R.Spring, D.West, R.Woods, R.Southeast)

Or

path(R.Woods, D.Southeast, D.West, R.Spring)

That way reads sort of left to right.

Suppose that a Path was a Pair of directions …

val fromSeToWest = Path(D.Southeast, D.West)

connect(R.Woods, fromSeToWest, R.Spring)

Could we somehow define all the possible paths in another enum?

connect(R.Woods, P.SeToWest, R.Spring)

There are 64 possible regular paths, 8 compass points leaving and 8 arriving. A number of them are unlikely, like P.NorthToNorth, but would be possible.

Maybe done with clock? P.From4To9? I imagine one could learn to use that. Weird, though.

How about:

connect(R.Woods, P.SE, P.W, R.Spring)

And we could overload for

connect(R.Woods,P.SE, R.Spring) { hasIventory("compass") }

I think the anonymous function would have to apply only to the left-to-right reading, so that if we wanted a condition on the other side, we’d have to use the other connection order:

connect(R.Spring, P.W, R.Woods) { 
	say("I hope you're not afraid of bears!")
}

I’m tempted to try this.

Now in another direction …

I was thinking that given that we are defining all the rooms up front in the enum, we could create the room right at the beginning, in an initialization, which might allow us to link things together directly rather than indirectly by name.

I think we could readily rig the DSL to do that. Here’s room now:

    fun room(name: R, details: Room.()->Unit) : Room {
        val room = Room(name)
        rooms.add(room)
        room.details()
        return room
    }

If the rooms already existed, we would just fetch the room of that name rather than creating it. That would make it possible to code more than once for the same room, if we needed to. (I suppose it would also leave open the possibility of coding more than once by accident, and breaking something. But so would the code we have now.)

That seems like a good idea as well. We could add a one-shot variable if we want to disallow defining a room in more than one place.

Fixing XYZZY

In the tests, XYZZY is treated as a direction, if I’m not mistaken.

Ah. Upon examination, if a magic word is used to move you to a destination, the easiest handling is to treat it as a direction:

    fun castSpell(imperative: Imperative, world: World) {
        when (imperative.noun) {
            "wd40" -> {
                world.flags.get("unlocked").set(true)
                world.response.say("The magic wd40 works! The padlock is unlocked!")
            }
            "xyzzy" -> { move(imperative, world) }
            else -> { world.response.say("Nothing happens here.") }
        }
    }

Except … we’d like to get the “Nothing happens here” message, and that’s not going to happen. The move function just moves you or doesn’t. It doesn’t return a result.

I suppose it could … here’s move now. We just changed it yesterday:

    fun move(imperative: Imperative, world: World) {
        D.executeIfDirectionExists(imperative.noun) { direction:D ->
            val (targetName, allowed) = moves.getValue(direction)
            if (allowed(world)) world.response.nextRoomName = targetName
        }
    }

Ah. I’m glad we had this little chat. Magic words are defined in the tables:

    private fun makeVerbs(): Verbs {
        return Verbs(mutableMapOf(
            "go" to Phrase("go", "irrelevant"),
            "east" to Phrase("go", "east"),
            ...,
            "say" to Phrase("say", "irrelevant"),
            "look" to Phrase("look", "around"),
            "xyzzy" to Phrase("say", "xyzzy"),
            "wd40" to Phrase("say","wd40"),
        ).withDefault { Phrase(it, "none")})
    }

So maybe what we should do with magic words is cause them to call castSpell, sort of like they do now … and at the world level, define them with something like “Nothing happens here”, or “What do you want to lubricate with the wd40?”, and then in the room where something is to happen, you implement a new DSL word, maybe spell, that does whatever you want.

I’ll add that to the list and work on it, tomorrow or Monday.

No … we can do better. We already have the DSL command action, which is used like this:

action("take", "water") { imp->
    if (inventoryHas("bottle")) {
        inventorySetInformation("bottle", " of water")
        say("You fill your bottle with water.")
    } else {
        imp.say("What would you keep it in?") }
}

So we’re there. We can already do this, and can do it with action, I think …

The outcome …

In makeActions:

    private fun makeActions(): Actions {
        return Actions(mutableMapOf(
            Phrase("go") to { imp: Imperative -> imp.room.move(imp, imp.world) },
            Phrase("say", "wd40") to { imp: Imperative ->
                imp.world.say("Very slick, but there's nothing to lubricate here.")
            },
            Phrase("say") to { imp: Imperative ->
                imp.world.say("Nothing happens here!") },
            Phrase("take") to { imp: Imperative -> imp.room.take(imp, imp.world) },
            Phrase("inventory") to { imp: Imperative -> imp.world.showInventory() },
            Phrase("look") to { imp: Imperative-> imp.room.look()},
            Phrase() to { imp: Imperative -> imp.room.unknown(imp, imp.world) }
        ))
    }

Note that I’ve put in a special action for “say wd40”, and one for plain “say”. And in the Wellhouse, I add:

room(R.Wellhouse ) {
    desc("well house", "You are in a small house near a spring. " +
            "The house is sparsely decorated, in a rustic style. " +
            "It appears to be well kept.")
    item("bottle")
    item("keys")
    go(D.West, R.Spring)
    action("say", "xyzzy" ) {
        say("Something amazing happens here!")
    }
}

And the game does this:

Welcome to Tiny Adventure!
You are at a clear water spring. There is a well house to the east, and a wooded area to the west and south..
You find water.

> wd40
Very slick, but there's nothing to lubricate here.
spring
You find water.

> xyzzy
Nothing happens here!
spring
You find water.

> e
You are in a small house near a spring. The house is sparsely decorated, in a rustic style. It appears to be well kept.
You find bottle.
You find keys.

> wd40
Very slick, but there's nothing to lubricate here.
well house
You find bottle.
You find keys.

> xyzzy
Something amazing happens here!
well house
You find bottle.
You find keys.

So that’s just fine. I remove XYZZY from the Direction enum. I think we can figure out how to make “xyzzy” teleport to a chosen room in some straightforward way. First, commit Remove XYZZY as a Direction, remove castSpell, implement magic words with “say” “xyzzy”.

Now, since I’m just playing anyway, how about making it teleport from the wellhouse to somewhere.

The regular move looks like this:

fun move(imperative: Imperative, world: World) {
    D.executeIfDirectionExists(imperative.noun) { direction:D ->
        val (targetName, allowed) = moves.getValue(direction)
        if (allowed(world)) world.response.nextRoomName = targetName
    }
}

So we can do this in our action:

action("say", "xyzzy" ) {
    say("Swoosh!")
    response.nextRoomName = R.WoodsNearCave
}

With this result:

You are in a small house near a spring. The house is sparsely decorated, in a rustic style. It appears to be well kept.
You find bottle.
You find keys.

> xyzzy
Swoosh!
You are in the woods. There is a cool breeze coming from the west.

Quite satisfactory. Commit: xyzzy in wellhouse leads to woods near cave.

I needed to modify two tests to the “new” scheme with action. Committed “update tests”.

Let’s sum up.

Summary

Once I got down to it, using action to handle magic words and similar things turns out to work quite nicely. What is troubling, though, is that I wasn’t on top of the idea. I thrashed around a bit before I sorted out in my mind how the Lexicon works. We can define general actions in the makeActions function, and room-specific ones in the room, using action. It works quite nicely, because of the way the tables are searched for specific commands, then more general ones, until finally we get down to “I don’t understand”. That’s here, in class Actions:

class Actions
    ...
    private fun find(imperative: Imperative): Action {
        val p = Phrase(imperative.verb, imperative.noun)
        return map.getOrElse(p) {
            map.getOrElse(p.asVerb()) {
                map.getOrElse(p.asNoun()) {
                    map.getValue(Phrase()) }
            }
        }
    }

We have two Actions in plan, a Room one and the World one. We always interrogate the Room one first, and if the room handles the Phrase, we don’t call the World, otherwise we do.

I had forgotten how nicely that works, having been away from building more game play, while doing all the fun refactoring with R and D and so on.

If I had been more assiduous in improving my tests, I think I’d have readily found example code that would have led me to the right place sooner. And now, I have those tests. But I’m a bit concerned that there may be more loose ends in the program that need tying up. We’ll have to look into that over the next few days.

I have very mixed feelings here. Things are going quite smoothly, but it often feels ragged to me. I’m not sure what this should suggest. There certainly are places where things are only just barely in place. But it almost feels as if I don’t know how my own program works. That’s odd … must think about this.

See you then, I hope!