GitHub Repo

Yesterday’s work on using the DSL turned up some issues. None of them surprise me—but that doesn’t mean I know how to deal with them. Or … do I? (And: I seem to be feeling whimsical.)

I started a new world definition yesterday, and I started right off with something difficult. Basically the scenario is this:

Scenario
You find yourself where there is water. You try to take it. You can’t take it, because you don’t have a container. Somewhere you find a bottle and take it, then try to take the water. You get the water, but unlike what usually happens when you take something, there is still water there. It’s a spring, y’all, not a puddle. Meanwhile, messages need to come out at appropriate times, such as “how would you carry it?” and “You fill your bottle with water”. And when you do the inventory command, we’d like it to say “an empty bottle” when the bottle is empty, or “a bottle of water” when it is full.

This is asking a lot of a room contents / inventory item: they are just strings like “keys” or “bottle”. So far, when someone takes “keys”, we’ve just looked in the room contents for “keys”, and if it’s there, remove it and add it to the player’s inventory, all with just plain vanilla strings.

Our “design”, which could be described 1 as “just passing strings around, no useful objects at all, what tiny fool wrote this”, might be thought of as inadequate to the need. Presumably your intrepid author knows enough about text games to have foreseen the issue. Why didn’t he already provide for it in the design?

It turns out that there are reasons:

  1. Owing primarily to tragic situations in my youth, I like to deliver visible features in my software, on a regular basis. That often means that my features are very lightweight, the absolute minimum needed to get it so you can take the keys. I think that a continual flow of features out helps to ensure a continual flow of income and praise coming in.

  2. Sooner or later, almost every software effort discovers weak parts of its design, or parts that no longer serve the need, and they need to deal with the situation without massive rewrites, huge refactorings, or long delays. So I intentionally do less design than one might ideally do. My experience is that this actually helps with the desire to deliver features as needed, with the caveat that when we encounter something whose design is inadequate, we improve the design, moving it in a better direction. Usually, however, we continue to improve it only as much as we must, as little as we can, because too much design improvement slows down feature delivery.

  3. Working like this might help my readers2. I hope that encountering some design issue like this one helps my readers gain comfort with the fact that their design may sometimes not serve as well as it might, and confidence that they can probably improve it incrementally, growing the design and the program’s capabilities smoothly, at the same time.

  4. I enjoy working this way. I am continually presented with puzzles that are fun to solve. I work to solve them in tiny steps rather than big chunks of effort. I figure out ways to test new design ideas off to the side, then slide them into place when they’re ready.

The Item

OK, enough chatter. We need to do something about these room / inventory items. Let’s glance at the current DSL to see what we have and what we seem to need.

fun makeGameWorld(): World {
    val theWorld = world {
        room("spring") {
            desc("spring", "You are at a clear water spring. There is a well house to the east, and a wooded area to the west and south.")
            item("water")
            item("bottle")
            go("e", "well house")
            go("w", "woods")
            go("s", "woods toward cave")
            action(Phrase("take", "water")) { imp
                ->  if (inventoryHas("bottle")) {
//                    addToInventory("bottle of water")
//                    removeInventory("empty bottle")
                    contents.add("water")
                    say("You have filled your bottle with water.")
            } else {
                imp.say("What would you keep it in?") }
            }
        }
        room("woods") {
            desc("woods", "You are in a dark and forbidding wooded area. It's not clear which way to go.")
            go("e", "spring")
            go("n", "woods")
            go("2", "woods")
            go("w", "woods")
            go("nw", "woods")
            go("se", "woods")
        }
    }
    return theWorld
}

We see two key sections here. The item word tells the Room to store an item there. The action section is a little lambda, a procedure for dealing with the player typing “take water”. I trust that it is fairly easy to understand. I’m not saying it’s great, but filling a bottle is likely a one-off operation, so if the code for “take water” is a bit complicated, I’m OK with it. But we’re not even close to dealing with things like getting the inventory to say “an empty bottle” or “a bottle of water”, depending on bottle state. Bottle’s a String. It has no state.

Here’s the code ford item, in Room:

    fun item(thing: String) {
        contents+=thing
    }

Not too special. What is contents?

    val contents = mutableSetOf<String>()

I’m fine with it being a set3, but we need an object. Let’s call it Thing. Maybe InventoryItem would be better, but Thing will do. Let’s assume that it’s a class with some decent behavior: we know that at least one of the Things has some ability to change how it describes itself.

I am tempted … very tempted … to decide that there should be two distinct kinds of Things, SimpleThings that just have a name and possibly a display name of “an axe” versus “a knife”, and SmartThings that have internal state like “full”. Maybe even “full of water” or “full of gasoline”. Then there’d be an interface Thing and two (or more) implementors.

That’s more design than I am generally inclined to do. I’m not usually the kind of guy who uses interfaces: I’m duck-typing all the way down. I’m tempted, though. Let’s hold off. If we do need an interface, and I suspect we may, we’ll see how hard it is to create it after the fact.

There’s one design aspect that I think is pretty clear: we’re going to want to say things about our Things, in the DSL, just as we now say things about the Room. In other words, the item term in the DSL needs details.

Look at the details for a Room:

world {
    room("woods") {
        desc("woods", "You are in a dark and forbidding wooded area. It's not clear which way to go.")
        go("e", "spring")
    }
}

The desc and go are methods on Room:

    fun desc(short: String, long: String) {
        shortDesc = short
        longDesc = long
    }
    fun go(direction: String, roomName: String, allowed: (World)->Boolean = { _:World -> true}) {
        moves += direction to Pair(roomName, allowed)
    }

And room is a method on world, that does the trick to allow for desc and go to talk to the room being created:

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

The pattern is always the same: we create the thing, save it where it needs saving, and then return it. (To tell the truth, I’m not sure why we need to return it. I think we were trying to allow for folks to save them during testing4.)

Aaaanyway … we need to do a similar thing with the Room’s item command. In due time. First, let’s gin up our object with some TDD. And maybe, given that the keyword is item, we should name it Item. Yeah, probably.

TDDing the Item

I start with my standard hookup test, which is guaranteed to fail. I do that to be sure that I’ve configured my test file properly:

class ItemTest {
    @Test
    fun `hookup`(){
        assertThat(2).isEqualTo(3)
    }
}

I get the desired output:

Expecting:
 <2>
to be equal to:
 <3>
but was not.

Nice. Now let’s get to work. As is my practice, I’ll work in the test file until it’s a hassle, then move the class.

I think we need to change the Room contents. We’ll be coming at it with strings from commands, so having it be a map from String to Item makes some sense.

I should explain my thinking if we can call it that. I started writing this test:

    @Test
    fun `initial item`() {
        var myRoom = Room("y")
        val world = world {
            myRoom = room("x") {
                item("axe")
            }
        }
        assertThat(myRoom.contents).hasE
    }

Then I got into drilling down a bit to see how the contents are handled. I nosed around various classes and methods and zeroed in on take:

    val take = { imperative: Imperative, world: World ->
        val done = contents.remove(imperative.noun)
        if ( done ) {
            world.addToInventory(imperative.noun)
            world.response.say("${imperative.noun} taken.")
        } else {
            world.response.say("I see no ${imperative.noun} here!")
        }
    }

I started thinking about what I’d have to change. Let’s not do that. Instead, let’s have our item code do what we want and chase down the changes as needed. I think we can probably trust our tests.

Aside
I’m not certain of that. If I find myself needing to run the game, I’ll beef up the tests.
    @Test
    fun `initial item`() {
        var myRoom = Room("y")
        val world = world {
            myRoom = room("x") {
                item("axe")
            }
        }
        val item: Item = myRoom.contents["axe"]
        assertThat(item.inventoryName).isEqualTo("an axe")
    }

This5 will suffice to make something happen. IDEA knows I can’t index contents that way, and that there is no such thing as an Item. Let’s write just a bit of code. We’ll need a rudimentary Item, we’ll need to create one in Room.item, and we’ll need to store it by key in the contents:

class Item(name: String) {
    
}

Then convert this:

    fun item(thing: String) {
        contents+=thing
    }

To this:

    fun item(thing: String) {
        contents[thing] = Item(thing)
    }

I’m putting the item class into play, so I need to move it out already. OK, so be it, I move it up to the main hierarchy.

IDEA and Kotlin inform me that I might not get my item back: there might not be one.

    @Test
    fun `initial item`() {
        var myRoom = Room("y")
        val world = world {
            myRoom = room("x") {
                item("axe")
            }
        }
        val item: Item? = myRoom.contents["axe"]
        assertThat(item.name).isEqualTo("axe")
    }

I backed off from inventoryName there. Step was too big. I still have some issues before the tests will run:

    fun itemString(): String {
        return contents.joinToString(separator = "") {"You find $it.\n"}
    }

This needs to refer to the keys, for now. We’ll want to do better. No, we can do better now. No again, we don’t want to lose the thread. Just this:

    fun itemString(): String {
        return contents.keys.joinToString(separator = "") {"You find $it.\n"}
    }

Next error?

    val take = { imperative: Imperative, world: World ->
        val done = contents.remove(imperative.noun)
        if ( done ) {
            world.addToInventory(imperative.noun)
            world.response.say("${imperative.noun} taken.")
        } else {
            world.response.say("I see no ${imperative.noun} here!")
        }
    }

It tells me that done is no longer a boolean, like Set.remove provides. Presumably it is the element, or null. I’d best check the docs to be sure. Right. So … this will do for now … I’m feeling this is taking too long, so I’m rushing to avoid the need to roll back. I think I’m close.

My new action breaks now:

    action(Phrase("take", "water")) { imp
        ->  if (inventoryHas("bottle")) {
//            addToInventory("bottle of water")
//            removeInventory("empty bottle")
            contents.add("water")
            say("You have filled your bottle with water.")
    } else {
        imp.say("What would you keep it in?") }
    }

I think this will do it .. no. In fact, the contents add is unnecessary. We’re not removing the water, no need to re-add it. Bug in my mind. Yesterday. All better now.

One more error:

Only safe (?.) or non-null asserted (!!.) calls are allowed on a nullable receiver of type Item?

Right.

        val item: Item = myRoom.contents["axe"]!!
        assertThat(item.name).isEqualTo("axe")

It bloody well better be there. Test.

Another test fails:

    val room = world.unsafeRoomNamed("storage")
    assertThat(room.contents).contains("broom")

Does the map understand containsKey? It does. Change, test. We are green. Woot!

Commit: Initial use if Item in new room contents, a map from name to Item.

Whew. I was getting fearful. But it’s still just very few changes, and didn’t take long. Let’s reflect and plan.

Reflecto-Plan

We now have a little class, Item, which has a name and nothing else, yet. We’re keeping them in a map from name to Item in Room contents. When we put them in the player’s inventory, we just put the name into a set if I’m not mistaken:

class world ...
    fun addToInventory(item:String) {
        inventory += item
    }

Right. Clearly this should be a map as well. And … very likely … it should be a class, probably named Items, that gives us the ability to do manipulations on the map, and perhaps even change its type if we need to.

I’ll try starting with just a typealias.I’ll change the World version to a map as well, which will break some more tests and code.

typealias Items = MutableMap<String, Item>

class World ...
    private val inventory: Items = mutableMapOf<String,Item>()

class Room(val roomName: String) {
    val contents = mutableMapOf<String, Item>()

OK, some things to fix, but world at least has some interface methods to help us:

    fun addToInventory(item:String) {
        inventory += item
    }

Let’s change this to expect an Item while we’re at it. Whoever’s calling it will just have to deal with it. (We could, I suppose, override and create the item here, but that seems wrong: items will be in the rooms.)

    fun addToInventory(item:Item) {
        inventory[item.name] = item
    }

I’ll let the compiler and tests guide me, though I see some things needing change here. No sense thinking when the computer will do it for me … ?

    private fun showInventory() {
        say( inventory.keys.joinToString(prefix="You have ", separator=", ", postfix=".\n") )
    }

That’ll do for now. Also looks like an interesting candidate for a method on an Items class.

This:

    val take = { imperative: Imperative, world: World ->
        val item = contents.remove(imperative.noun)
        if ( item!=null ) {
            world.addToInventory(imperative.noun)
            world.response.say("${imperative.noun} taken.")
        } else {
            world.response.say("I see no ${imperative.noun} here!")
        }
    }

Needs to say this:

    val take = { imperative: Imperative, world: World ->
        val item = contents.remove(imperative.noun)
        if ( item!=null ) {
            world.addToInventory(item)
            world.response.say("${item.name} taken.")
        } else {
            world.response.say("I see no ${imperative.noun} here!")
        }
    }

Test. In a test we see these:

        world.addToInventory("axe")
        world.addToInventory("bottle")

They need to be:

        world.addToInventory(Item("axe"))
        world.addToInventory(Item("bottle"))

Test. Green! Commit: World inventory is an Items, as is Room contents. Items merely typealias for now.

OK, I’ve been in here for a couple of hours. Time for a break. Let’s sum up, I’ll push this article and then pick up next time.

Summary

We’ve observed a fatal flaw in our design: strings don’t cut it as things to be put in room contents and inventory: we need something smarter. In a couple of quick commits, we’ve created a new object, Item, and converted World and Room to use it instead of strings. We’ve also created a little crevice into which a smart collection, Items, should be able to fit quite nicely.

I think there might be a smoother way to set up for Items, taking advantage of Kotlin’s extensions or something, but this seems simple enough to me. I’ll do a little reading on my break. Maybe.

The Item object will be a place to put some useful methods to make the game seem more alive, by providing articles like “a” or “an”. It sees that it’d be useful to allow for a detailed description, and a new command. Perhaps “examine axe” should say something like

This is a lovely hand-forged axe. Its handle is engraved “Bridget Ingridsdotter”, probably the name of the axe’s troll owner. The blade is acid etched with a dragon’s head, with a gold-inlaid eye. The blade appears to have adventurer blood stains on it …

Aside
These little excursions cause me to want to create a real game of this, full of puzzles and whimsy, with Easter egg references to old games like the original Colossal Cave and Zork. But why? How would I distribute it, and who would even play it? Still … I’m tempted.

I wrote item!=null somewhere up there. I’m not fond of that, even though Kotlin and IDEA are good about reminding me that it’s necessary. I’ll probably want to find something better there. The Items collection may provide some help there.

Maybe we could provide two expressions when we request something from the inventory, automatically executing the one for the “it’s there” case or the “it’s not here” case … might be fun.

Anyway, we have a bit of a place to stand, as a foundation for our complicated dance with the bottle and the water. We’ll continue next time!



  1. But seriously, nearly every design has flaws, mistakes, messes large and small, issues, horrid traps … you know, the stuff of everyday work. We do not blame ourselves for that: blame doesn’t make us better. We do observe that we did write this stuff, or if we didn’t, we’ve probably done just as badly somewhere else. We’re about observing the code and making it better. 

  2. I say “readers”, like I’m sure there’s more than one. Or more than zero. Let’s face it, I’d do this even if no one were reading. Bueller? Bueller? 

  3. Ha! Fine with it being a set? That’ll last about nineteen seconds. It needs to be a map, which is sort of like a set with delusions of complexity. 

  4. But it turns out we’re going to use that feature just a few paragraphs down. And actually glad to have it. 

  5. Did you notice the myRoom above? I just used the return from the room function that I was wondering about up above. Strange how things happen, isn’t it?