I want to set up a test world where you have to say a magic word to open a door, or something like that.

I’m thinking of this in terms of a story where you can open a door only if you have the key, but I’m working up to it. I don’t want to have to implement a lot of Picov Andropov code1 all at once, in the spirit of small steps. Let’s see, maybe a scenario like this:

  1. You’re in a room. There is a padlocked door to the east.
  2. Try to go east: “The door is padlocked. You don’t have a key.”
  3. “cast wd40”. “The lock falls open easily!”
  4. Try to go east: “You find yourself in a room full of treasure.”

Let’s code up a game test. Hmm, this is rather tedious. Perhaps a Room test would be a better idea, and I should be less fancy with the messages.

    @Test
    fun `magic unlocks door`() {
        val world = world {
            room("palace") {
                desc("You are in an empty room.",
                    "You are in an empty room in the palace."
                            + "There is a padlocked door to the east")
                go("e","treasure") {
                    if (it.status.unlocked)
                        true
                    else {
                        it.say("The room is locked by a glowing lock")
                        false
                    }
                }
            }
            room("treasure") {
                desc("You're in the treasure room",
                    "You are in a treasure room, rich with gold and jewels")
                go("w", "palace")
            }
        }
        val game = Game(world,"palace")
        game.command("e")
        assertThat(game.resultString).isEqualTo("hello")
    }

Kotlin rightly points out that GameResponse does not have a status member. I have in the back of my mind the intention of passing world to the blocks, not just the response, so that scripts can keep track of details over longer periods. But for now …

I think I’ll do this …

    val status = mutableMapOf<String,String>()

And we’ll have an error as the code is written. Let’s look again:

                go("e","treasure") {
                    if (it.status.unlocked)
                        true
                    else {
                        it.say("The room is locked by a glowing lock")
                        false
                    }
                }

Let’s say this:

        if (it.status.getOrDefault("unlocked", false))

That’ll force me to make the map from string to boolean:

    val status = mutableMapOf<String,Boolean>()

I’m sure there’s a cleaner way to do this but I am in essence spiking this solution, though I do not plan to throw it away if it goes at all well. Let’s run the test.

Expecting:
 <"The room is locked by a glowing lock
You are in an empty room in the palace.There is a padlocked door to the east">
to be equal to:
 <"hello">
but was not.

Just as I had hoped. I’ll accept that string as the expected.

        assertThat(game.resultString).isEqualTo("The room is locked by a glowing lock\n" +
                "You are in an empty room in the palace.There is a padlocked door to the east")

We are green. Commit. Everything works, even though the feature isn’t entirely done. Commit: GameResponse has status map from string to boolean flag.

Ah, discovered what a red file name means in IDEA: not in Git. Fixed. Now let’s cast the spell and make that work.

        val game = Game(world,"palace")
        game.command("e")
        assertThat(game.resultString).isEqualTo("The room is locked by a glowing lock\n" +
                "You are in an empty room in the palace.There is a padlocked door to the east")
        game.command("cast wd40")
        assertThat(game.resultString).contains("unlocked")
        game.command("e")
        assertThat(game.resultString).contains("rich with gold")

I’ll try that. Should be enough to drive out some code.

Expecting:
 <"You are in an empty room in the palace.There is a padlocked door to the east">
to contain:
 <"unlocked"> 

I am surprised not to get two messages but I will deal with that later. For now we know that cast wd40 is not recognized. I’ll give it a special method for now:

    fun command(cmd: String, response: GameResponse)  {
        val name = when(cmd) {
            "s","e","w","n" -> move(cmd, response)
            "xyzzy" -> move("xyzzy", response)
            "cast wd40" -> castSpell("wd40", response)
            else -> "unknown cmd $cmd"
        }
        response.nextRoomName = name
    }

And, of course …

    private fun castSpell(spell: String, response: GameResponse): String {
        response.status.put("unlocked", true)
    }

I rather expect my test to pass. I am mistaken. All my methods have to return a room name, so:

    private fun castSpell(spell: String, response: GameResponse): String {
        response.status.put("unlocked", true)
        return roomName
    }

Test. Red. Oh, forgot to say anything.

    private fun castSpell(spell: String, response: GameResponse): String {
        response.status.put("unlocked", true)
        response.say("The magic wd40 works! The padlock is unlocked!")
        return roomName
    }

Test. Red. Ah:

Expecting:
 <"The room is locked by a glowing lock
You are in an empty room in the palace.There is a padlocked door to the east">
to contain:
 <"rich with gold"> 

The response is new for each command. We simply cannot store long term status in it. You’ll recall that I expected that, and planned to pass down the world rather than the response. Let’s change that rule and see what IDEA and Kotlin tell us about fixing it up.

I have good news and bad news. The good news is that IDEA and Kotlin dragged me through all the necessary changes to convert to passing a World instead of a GameResponse everywhere. The bad news is that the changes were very extensive, quite the opposite of a small Atomic Change such as I wrote about just this morning.

The changes were, in some sense Atomic, since they all had to be made for the program to work again. I think I’d argue that they were more like a large organic molecule, or perhaps a very heavy and probably radioactive U238 atom, where a nice hydrogen would have been better. Maybe a helium. Not a U238.

But Kotlin and IDEA and my tests did walk me through it all.

Summing Up

I think I’ll spare us reviewing the changes. I did go through the diffs and they’re all pretty obvious changes:

  • Change the signature of a method to expect a World rather than a Response;
  • Change response references in the method to world.response
  • Fiddle some Room tests to make sure their world has a response.

The most irritating thing was that I had to declare the World’s response instance var. It had been private var. That was needed by the tests.

I think over the next couple of days, we’ll encounter some bumpy code and improve it, but the fact is that the wd40 scenario runs as intended.

The status dictionary is just a map from string to boolean, which cannot long endure. I’m sure we’ll want other kinds of status, and that we’ll need a much more sensible kind of object. But that will come. We’ll bear down a bit to make what we have a bit more clean, but I think the objects are mostly in the right place. Possibly … I’m not sure … we should have given the response a copy of the world, and left everyone talking to a GameResponse, but that creates a circular reference that I’d like to avoid.

I do wish I had found a more incremental way. Perhaps the test I first wrote was too large, driving me to a larger solution space. It’s tempting to undo these changes, and try again but this time, I’m not going to do that.

I’m following a general idea here. In prior game articles, I’ve had circular references with the game knowing the dungeon and the dungeon knowing the game and such. Here, I’m trying to pass whatever context is needed to the methods that need it. So far, I think the idea is holding up pretty well.

But I did make a bit of a mess here today, and it’ll need cleaning up. But let’s get real: we still aren’t even parsing commands. It’s early days!

See you tomorrow if all goes well, and we’ll see what happens next.



  1. Homage to Car Talk Brothers’ Russian chauffeur, referring here to not wanting to code all the “pick up” and “drop off” inventory stuff before I can open a door.