GitHub Repo

Two stories loom before me: some kind of a magic bridge, and darkness. One of these is easy.

The magic bridge is rather like a locked gate with different dialog. Seems it’ll be easy.

Darkness, though. Many rooms are dark, but not all. In a cave most places would be dark. (Hey! I should have called them place, not room!) But outdoors most places would probably have light. It would be irritating and error-prone to require specifying in every place whether it is dark or not, though we could probably rig something so that the DSL wasn’t satisfied until it knew. Then there is the matter of what happens in the dark. Presumably you fall into a pit or are eaten by a grue or something equally dire. And, finally, there’s the rigmarole around providing light, presumably with a lamp or lantern that can run out of fuel or electrons.

It’s clear on the face of it that the story Darkness can be broken down into various smaller stories, each of which could serve as a reasonable step along the way to whatever capability we ultimately decide upon. We could make a single dark room, with a certainty of something horrible if you try to move while in the dark. Then we could address the odds of something bad happening; a selection of bad things to have happen; making the bad things room-dependent; a simple on/off light; a duration of lamp life, perhaps measured in number of rooms visited with the light on; and so on.

I was reading a transcript of a run through of Adventure or Zork, and when the room was dark, the game displayed what looked like a standard room description, but that was just “Darkness” with some additional commentary. It made me think … perhaps there is a single room, Darkness, and when it gets dark you are beamed there, and when it gets light you are beamed back to where you were. Then Darkness handling could be done all in one room. That could be nifty.

One disadvantage to that is that we could imagine that we want to allow the player to move while in the dark. For an easy example, if you go west and find yourself in the dark, going east should perhaps bring you back out into the light, in the room whereby you entered the darkness. If you don’t die, that is.

Still, managing all the darkness stuff with a single designated place really sounds like it could be a good idea. Even things like trying to move could be handled from within Darkness, if it had a pointer to the room you used to be in, which it would have to have, if it was going to send you back.

I’m liking this idea.

Let’s write a test.

Darkness Room

This test just runs:

    @Test
    fun `darkness room`() {
        val world = world {
            room(R.Z_FIRST) {
                desc("You are in nondescript room.",
                    "You are in a nondescript room. A darkened hall leads south.")
                go(D.South, R.Darkness)
            }
            room(R.Darkness) {
                desc("Darkness", "Darkness. You are likely to be eaten by a grue.")
                go(D.North, R.Z_FIRST)
            }
        }
        val player = Player(world, R.Z_FIRST)
        var result = player.command("s")
        assertThat(result).contains("grue")
        result = player.command("n")
        assertThat(result).contains("nondescript")
    }

Now what we’d rather like to have happen is “bad things” if we find ourselves in this room twice in a row, with no intervening rooms. How might we do that?

We don’t have any code that runs at room entry or exit. If we had those, it might come in handy here: we could tick a counter or something. We don’t have a way to know that a command has been done but that we didn’t handle it in our go and action commands. Or have we? There’s a default command in room actions. Maybe we can override that somehow.

class Room(val roomName: R, private val actions: IActions = Actions()) : IActions by actions {
    init {
        actions.add(Phrase()) { imp -> imp.notHandled() }
    }

What happens if we add another action with an empty phrase? How does Actions.add work? Ah …

    override fun add(phrase: Phrase, action: Action) {
        map[phrase] = action
    }

Let’s try that. We don’t have quite what we need. Here’s the interface we’re using, and its implementor:

interface IActions {
    fun act(imperative: Imperative)
    fun action(verb: String, noun: String, action: Action)
    fun action(verb: String, action: Action)
    fun action(commands: List<String>, action: Action)
    fun add(phrase: Phrase, action: Action)
    fun clear()
}


class Actions() : IActions {
    override fun action(verb: String, noun: String, action: Action) {
        add(Phrase(verb, noun), action)
    }

    override fun action(verb: String, action: Action) {
        add(Phrase(verb), action)
    }

    override fun action(commands: List<String>, action: Action) {
        commands.forEach { makeAction(it, action) }
    }

I wonder if Phrase will create an empty phrase with no strings provided?

data class Phrase(val verb: String?=null, val noun: String?=null) {
    fun asVerb() = Phrase(this.verb)
    fun asNoun() = Phrase(noun=this.noun)
    fun asEmpty() = Phrase()
    fun asImperative() = Imperative(this)
    fun setNoun(noun: String): Phrase {
        return Phrase(this.verb,noun)
    }
}

Looks like it might just work. I’ll try something in the test. No, I’m going to have to extend the interface and class. No biggie:

interface IActions {
    fun act(imperative: Imperative)
    fun action(verb: String, noun: String, action: Action)
    fun action(verb: String, action: Action)
    fun action(action:Action) // <---
    fun action(commands: List<String>, action: Action)
    fun add(phrase: Phrase, action: Action)
    fun clear()
}

    override fun action(action: Action) {
        add(Phrase(), action)
    }

That should let me do this!

    room(R.Darkness) {
        desc("Darkness", "Darkness. You are likely to be eaten by a grue.")
        go(D.North, R.Z_FIRST)
        action() {
            say("You have been eaten by a grue!")
        }
    }

That may override moving north. We’ll see. Extend the test to what we really want to have happen:

    val player = Player(world, R.Z_FIRST)
    var result = player.command("s")
    assertThat(result).contains("grue")
    result = player.command("n")
    assertThat(result).contains("nondescript")
    result = player.command("e")
    assertThat(result).contains("You have been eaten")

The result:

Expecting:
 <"You have been eaten by a grue!
Darkness
">
to contain:
 <"nondescript"> 

As I feared, the action overrides the move. What usually happens is that the room’s actions do not include directions, and so the world default (which we have overridden here) handles moving. We’ll have to handle it ourselves. Let’s see what the imperative is that comes to us on the action.

A quick addition to the say tells us that when we tried to move north, the verb=”go” and the noun=”north”. We can work with that.

We can get fancy here, field “go” and then search our moves table to see where we can go, but let’s save that work for later and do something simple:

    @Test
    fun `darkness room`() {
        val world = world {
            room(R.Z_FIRST) {
                desc("You are in nondescript room.",
                    "You are in a nondescript room. A darkened hall leads south.")
                go(D.South, R.Darkness)
            }
            room(R.Darkness) {
                desc("Darkness", "Darkness. You are likely to be eaten by a grue.")
                go(D.North, R.Z_FIRST)
                action() {
                    if (it.verb=="go" && it.noun=="north") {
                        response.moveToRoomNamed(R.Z_FIRST)
                    } else {
                        say("You have been eaten by a grue!")
                    }
                }
            }
        }
        val player = Player(world, R.Z_FIRST)
        var result = player.command("s")
        assertThat(result).contains("grue")
        result = player.command("n")
        assertThat(result).contains("nondescript")
        result = player.command("s")
        result = player.command("e")
        assertThat(result).contains("You have been eaten")
    }

We “just” check for “go north” and move to the room we put in the go command. This test runs.

This has been a pleasant little experiment. I think we have a foothold. Let’s commit: action DSL can override default: use with great care, i.e. not at all unless you are a wizard. Test in RoomTests shows a darkness room.

Summary

I think that’ll do for this morning. Time for my chai and banana, which I forgot to make before I sat down. Also Sunday brekkers coming.

We’ve built a simple room “Darkness” that shows promise. Along the way, we implemented a way to field all the commands that the player types in a room. And — I wonder — if we were to set the “world handles this flag”, would that have worked?

OK, I gotta try that. Belay the brekkers, but I’m going for the chai.

    action() {
        if (it.verb=="go" && it.noun=="north") {
            it.notHandled()
        } else {
            say("You have been eaten by a grue!")
        }
    }

The notHandled is what the old default action did. It sets a flag in the command processing that tells it to continue to try the world commands. So if the notHandled works as I think it does, we can create a room that takes over all commands, but defaults any that it wants processed back to the world. Run the test expecting green. Green it is. Commit: darkness room test uses itnoHandled() to defer a command back to world processing.

Very nice. The way things code up in this thing makes me feel good about it. It seems that the objects and commands fall to hand nicely. New features have gone in readily, and very rarely do we have to do anything really strange or difficult to get something to work. Most changes come down to one or two lines, each in their own function, not sprinkled around in other functions. That’s a very good sign indeed, if I do say so myself.

A pleasant morning exercise. See you next time!