Kotlin 41
I think I’ll experiment a bit with a Command object and its context. I’m not sure where this will lead. Perhaps to the bit bucket.
It would be wise, I imagine, to start with the existing tests, and to extend the class compatibly as far as possible. With more luck than I deserve, or more skill than perhaps I have, we might be able to make plugging it into the game quite smooth. Failing that, we’ll learn something.
I’ll start with a review of the tests and code:
class CommandExperiment {
@Test
fun sequence() {
val command = Command("take axe")
val result = command
.validate()
.execute()
assertThat(result).isEqualTo("axe taken.")
}
@Test
fun `go command`() {
val command = Command("go east")
val result = command
.validate()
.execute()
assertThat(result).isEqualTo("went east.")
}
@Test
fun `expand magic`() {
val command = Command("xyzzy")
command.makeWords()
assertThat(command.words.size).isEqualTo(1)
command.magicWords()
assertThat(command.words.size).isEqualTo(2)
assertThat(command.words[0]).isEqualTo("say")
assertThat(command.words[1]).isEqualTo("xyzzy")
// command.makeVerbNoun()
assertThat(command.verb).isEqualTo("say")
assertThat(command.noun).isEqualTo("xyzzy")
}
@Test
fun `single word go commands`() {
val command = Command("east")
val result = command
.validate()
.execute()
assertThat(result).isEqualTo("went east.")
}
@Test
fun `single magic word commands`() {
val command = Command("xyzzy")
val result = command
.validate()
.execute()
assertThat(result).isEqualTo("said xyzzy.")
}
@Test
fun `single known words with ignored noun`() {
val command = Command("inventory")
val result = command
.validate()
.execute()
assertThat(result).isEqualTo("Did inventory with 'ignored'.")
}
@Test
fun `single unknown word commands`() {
val command = Command("fragglerats")
val result = command
.validate()
.execute()
assertThat(result).isEqualTo("I don't understand fragglerats.")
}
@Test
fun `examine command error`() {
val command = Command("vorpal blade")
val result = command
.validate()
assertThat(result.verb).isEqualTo("vorpal")
assertThat(result.noun).isEqualTo("blade")
}
@Test
fun `too many words`() {
val command = Command("too many words")
val result = command
.validate()
.execute()
assertThat(result).isEqualTo("I understand only one- and two-word commands, not 'too many words'.")
}
}
class Command(val input: String) {
val words = mutableListOf<String>()
var operation = this::commandError
var result: String = ""
val verb get() = words[0]
val noun get() = words[1]
fun validate(): Command {
return this
.makeWords()
.oneOrTwoWords()
.goWords()
.magicWords()
.singleWordCommands()
.errorIfOnlyOneWord()
}
fun makeWords(): Command {
words += input.split(" ")
return this
}
fun oneOrTwoWords(): Command {
if (words.size < 1 || words.size > 2 ){
words.clear()
words.add("countError")
words.add(input)
}
return this
}
fun errorIfOnlyOneWord(): Command {
if (words.size == 2) return this
words.add(0,"verbError")
return this
}
fun findOperation(): Command {
operation = when (verb) {
"take" -> ::take
"go" -> ::go
"say" -> ::say
"inventory" -> ::inventory
"verbError" -> ::verbError
"countError" -> ::countError
else -> ::commandError
}
return this
}
fun goWords(): Command {
val directions = listOf(
"n","e","s","w","north","east","south","west",
"nw","northwest", "sw","southwest", "ne", "northeast", "se", "southeast",
"up","dn","down")
return substituteSingle("go", directions)
}
fun magicWords(): Command {
val magicWords = listOf("xyzzy", "plugh", "wd40")
return substituteSingle("say", magicWords)
}
fun singleWordCommands(): Command {
if (words.size == 2) return this
val ignoredNounWords = listOf("inventory", "look")
if (words[0] in ignoredNounWords) words.add("ignored")
return this
}
fun substituteSingle(sub: String, singles: List<String>): Command {
if (words.size == 2) return this
if (words[0] in singles) words.add(0, sub)
return this
}
// execution
fun execute(): String {
result = findOperation().operation(noun)
return result
}
fun commandError(noun: String) : String = "command error '$input'."
fun go(noun: String): String = "went $noun."
fun say(noun:String): String = "said $noun."
fun take(noun:String): String = "$noun taken."
fun inventory(noun: String): String = "Did inventory with '$noun'."
fun verbError(noun: String): String = "I don't understand $noun."
fun countError(noun: String): String = "I understand only one- and two-word commands, not '$noun'."
}
OK, fine, what shall we do? How about this? We don’t use the findOperations
or execute
methods in the game, we stop before that. So let’s begin by seeing if we can move those capabilities into a context of some kind, that would let us swap out our test ones and plug in our World ones when we’re ready.
I think we’ll wind up with an interface and two implementors, but we’ll just start with an object, CommandContext, that we can work with. Since the working commands won’t see it (yet), I can just create it safely.
class CommandContext
class Command(val input: String, context: CommandContext = CommandContext()) {
...
I think this should work just fine and it does. Not too surprised.
Let’s put the magic words into the context. By design, these are words that will be recognized as verbs and converted to verb=say, noun=magicWord. The code for them now is this:
fun magicWords(): Command {
val magicWords = listOf("xyzzy", "plugh", "wd40")
return substituteSingle("say", magicWords)
}
I’ll move the list to the context and refer to it here.
fun magicWords(): Command {
return substituteSingle("say", context.magicWords)
}
I had to declare context val
in the Command constructor. I expect green. Green it is. Commit: Command has CommandContext with magic words defined.
Now, I guess, the ignoredNounWords
:
fun singleWordCommands(): Command {
if (words.size == 2) return this
val ignoredNounWords = listOf("inventory", "look")
if (words[0] in ignoredNounWords) words.add("ignored")
return this
}
That becomes:
fun singleWordCommands(): Command {
if (words.size == 2) return this
if (words[0] in context.ignoredNounWords) words.add("ignored")
return this
}
class CommandContext{
val magicWords = listOf("xyzzy", "plugh", "wd40")
val ignoredNounWords = listOf("inventory", "look")
}
And the same for the directions:
fun goWords(): Command {
return substituteSingle("go", context.directions)
}
class CommandContext{
val magicWords = listOf("xyzzy", "plugh", "wd40")
val ignoredNounWords = listOf("inventory", "look")
val directions = listOf(
"n","e","s","w","north","east","south","west",
"nw","northwest", "sw","southwest", "ne", "northeast", "se", "southeast",
"up","dn","down")
}
We are green. Commit: CommandContext holds all Command vocabulary tables.
Let’s convert CommandContext to an interface, because we can be pretty sure that the game version will need a different implementation. IDEA helps me this far:
interface ICommandContext {
val magicWords: List<String>
val ignoredNounWords: List<String>
val directions: List<String>
}
class CommandContext : ICommandContext {
override val magicWords = listOf("xyzzy", "plugh", "wd40")
override val ignoredNounWords = listOf("inventory", "look")
override val directions = listOf(
"n","e","s","w","north","east","south","west",
"nw","northwest", "sw","southwest", "ne", "northeast", "se", "southeast",
"up","dn","down")
}
I think I don’t like those names. I want the interface name to be CommandContext. What would it have done if I had said that? Let’s undo and try again, to find out.
OK, it’s not that smart. I’ll rename the concrete class TestCommandContext and then do the extract.
interface CommandContext {
val magicWords: List<String>
val ignoredNounWords: List<String>
val directions: List<String>
}
class TestCommandContext : CommandContext {
override val magicWords = listOf("xyzzy", "plugh", "wd40")
override val ignoredNounWords = listOf("inventory", "look")
override val directions = listOf(
"n","e","s","w","north","east","south","west",
"nw","northwest", "sw","southwest", "ne", "northeast", "se", "southeast",
"up","dn","down")
}
And now in the constructor of command, we find this:
class Command(val input: String, val context: TestCommandContext = TestCommandContext()) {
...
And we want this:
class Command(val input: String, val context: CommandContext = TestCommandContext()) {
...
And we’re where I want to be. Green. Commit: Command has CommandContext parameter, defaulted to TestCommandContext.
Now there’s the matter of execution. We could leave it alone, but what we “really” want, I believe, is to provide the list of functions to be executed after the parse, based on the verb. I think that to do that we need to convert all the existing execution methods to lambdas, because we use, and will continue to use, lambdas on the game side.
We have this:
fun findOperation(): Command {
operation = when (verb) {
"take" -> ::take
"go" -> ::go
"say" -> ::say
"inventory" -> ::inventory
"verbError" -> ::verbError
"countError" -> ::countError
else -> ::commandError
}
return this
}
fun commandError(noun: String) : String = "command error '$input'."
fun countError(noun: String): String = "I understand only one- and two-word commands, not '$noun'."
fun go(noun: String): String = "went $noun."
fun inventory(noun: String): String = "Did inventory with '$noun'."
fun say(noun:String): String = "said $noun."
fun take(noun:String): String = "$noun taken."
fun verbError(noun: String): String = "I don't understand $noun."
I’ll just see if IDEA will help me convert these to lambdas, or if I have to do it by hand. (I’m aware that we’ll need to change their parameter expectations before we’re lined up with the game version.)
I find no help in IDEA. The ones over in Room look like this:
val move = {command: Command, world: World ->
val (targetName, allowed) = moves.getValue(command.noun)
if (allowed(world)) world.response.nextRoomName = targetName
}
I’ll just edit them. How about the multi-cursor thing, can I do them all at once?
After about seventy-eleven tries, I get this:
val commandError = {noun: String -> "command error '$input'."}
val countError = {noun: String-> "I understand only one- and two-word commands, not '$noun'."}
val go = {noun: String-> "went $noun."}
val inventory = {noun: String-> "Did inventory with '$noun'."}
val say = {noun:String-> "said $noun."}
val take = {noun:String-> "$noun taken."}
val verbError = {noun: String-> "I don't understand $noun."}
OK that didn’t work and after quite a bit of time, I can’t see what to do, even trying to copy the code in Room. Roll back, try again.
I start this time by initializing operation to a lambda of the form I think I want:
var operation = { noun:String -> "initial operation" }
I am green. I’ll try converting just one easy one this time:
fun verbError(noun: String): String = "I don't understand $noun."
val verbError = {noun: String -> "I don't understand $noun."}
That seems to correspond with the ones in Room, e.g.
private val unknown = { command: Command, world: World ->
world.response.say("unknown command '${command.verb} ${command.noun}'")
}
Now up in findOperation
, it’s probably complaining. Yes it has red squiggles on verbError
and complains: Type mismatch, required (String)->String, found KProperty0<(String)->String>. I’ve seen this kind of thing before, but I don’t see what to do about it. Yet. If I just remove the ::, it works:
~~~kotlinnd { operation = when (verb) { “take” -> ::take “go” -> ::go “say” -> ::say “inventory” -> ::inventory “verbError” -> verbError “countError” -> ::countError else -> ::commandError } return this }
I thought I tried that earlier. Anyway, I'm green, so I'll try another one, perhaps a bit more complicated.
~~~kotlin
val take = {noun:String -> "$noun taken." }
Make the change in the when … still green. Do a couple more.
val commandError = {noun: String -> "command error '$input'." }
val go = {noun: String -> "went $noun." }
val say = {noun:String -> "said $noun." }
val take = {noun:String -> "$noun taken."}
With the :: removed above, we’re green. Commit, need a save point. Commit: operations converted except inventory and countError.
Do those:
fun findOperation(): Command {
operation = when (verb) {
"take" -> take
"go" -> go
"say" -> say
"inventory" -> inventory
"verbError" -> verbError
"countError" -> countError
else -> commandError
}
return this
}
val commandError = {noun: String -> "command error '$input'." }
val go = {noun: String -> "went $noun." }
val say = {noun:String -> "said $noun." }
val take = {noun:String -> "$noun taken."}
val inventory = {noun: String -> "Did inventory with '$noun'."}
val verbError = {noun: String -> "I don't understand $noun."}
val countError = {noun: String -> "I understand only one- and two-word commands, not '$noun'."}
Test, expecting green. Green, it is. Commit: All operations in Command test mode converted to lambdas expecting noun: String.
So that’s good. One more step seems to make sense: We move all the lambdas into a map from the string name to the lambda, and put that in the Context. We should also normalize the expectations of the two sets of lambdas for arguments. These ones just get the noun, the other ones currently get the Command itself, and the World. Given that the command has the Context, maybe we’ll assume that the Context has everything you need and just pass it. That sounds reasonable enough that let’s do it here. I’ll change them all to expect a CommandContext and see what happens.
No. Let’s go through the Command. We’ll pass the Command.
I’m green, and here’s what I’ve got:
class Command(val input: String, val context: CommandContext = TestCommandContext()) {
val words = mutableListOf<String>()
var operation = { command: Command -> "initial operation" }
...
fun findOperation(): Command {
operation = when (verb) {
"take" -> take
"go" -> go
"say" -> say
"inventory" -> inventory
"verbError" -> verbError
"countError" -> countError
else -> commandError
}
return this
}
val commandError = {command: Command -> "command error '${command.input}'." }
val go = {command: Command -> "went ${command.noun}." }
val say = {command:Command -> "said ${command.noun}." }
val take = {command:Command -> "${command.noun} taken."}
val inventory = {command: Command -> "Did inventory with '${command.noun}'."}
val verbError = {command: Command -> "I don't understand ${command.noun}."}
val countError = {command: Command -> "I understand only one- and two-word commands, not '${command.noun}'."}
Now we can make our map and use it. First, internally:
fun findOperation(): Command {
operation = operations.get(verb)!!
return this
}
val operations = mutableMapOf(
"go" to {command: Command -> "went ${command.noun}." },
"say" to {command:Command -> "said ${command.noun}." },
"take" to {command:Command -> "${command.noun} taken."},
"inventory" to {command: Command -> "Did inventory with '${command.noun}'."},
"verbError" to {command: Command -> "I don't understand ${command.noun}."},
"countError" to {command: Command -> "I understand only one- and two-word commands, not '${command.noun}'."},
"commandError" to {command: Command -> "command error '${command.input}'." }
)
Green. Commit: Command tests look up operations in table (using !!)
The !! means we are guaranteeing that the lookup will succeed. We need to do a bit better than that but this will hold for now. Now we can move the table to the context:
interface CommandContext {
val magicWords: List<String>
val ignoredNounWords: List<String>
val directions: List<String>
val operations: Map<String,(Command)->String>
}
class TestCommandContext : CommandContext {
override val magicWords = listOf("xyzzy", "plugh", "wd40")
override val ignoredNounWords = listOf("inventory", "look")
override val directions = listOf(
"n","e","s","w","north","east","south","west",
"nw","northwest", "sw","southwest", "ne", "northeast", "se", "southeast",
"up","dn","down")
override val operations: Map<String, (Command) -> String> = mutableMapOf(
"go" to {command: Command -> "went ${command.noun}." },
"say" to {command:Command -> "said ${command.noun}." },
"take" to {command:Command -> "${command.noun} taken."},
"inventory" to {command: Command -> "Did inventory with '${command.noun}'."},
"verbError" to {command: Command -> "I don't understand ${command.noun}."},
"countError" to {command: Command -> "I understand only one- and two-word commands, not '${command.noun}'."},
"commandError" to {command: Command -> "command error '${command.input}'." }
)
}
And use it here:
fun findOperation(): Command {
operation = context.operations.get(verb)!!
return this
}
We are green. Commit: Command tests and TestCommandContext have all tables, including operations, in the context per long-range (a day or two out) plans.
So, this is good, and more than good. Let’s sum up.
Summary.
In a few simple steps, with only one big chunk of confusion when I tried to convert the functions all at once, we’ve converted the three input tables, directions, ignoredNounWords, and magicWords, to live in a new CommandContext object, represented by an interface and one concrete class TestCommandContext. The context also holds a map of the lambdas for whatever verbs the program uses, each of which must have a lambda in the table. The verbs in the tests are commandError, countError, go, inventory, say, take, and verbError.
I think we’re in good condition to convert the game command code to use the notion of a context. In the game, the context will need to be more complex, but the complexity will be all inside, converting the four properties of the CommandContext interface into whatever it takes to access the relevant tables.
I’m sure there will be tweaking to do, especially around error handling and validation. In the fullness of time, I’d like to have the complete vocabulary defined using our DSL. We might even get there.
See you next time!