~macaptain/ghostspeller

d4eff35ff38daa242358a52cdb1eb13a0d1a1a31 — Michael Captain 9 months ago
Initial commit

Command line utility which lets you play Ghost against a (very strong)
computer. The computer plays an optimal strategy built by constructing
the given word list as a dictionary.
A  => .gitignore +23 -0
@@ 1,23 @@
.idea/
*.class
*.log
*.ctxt
.mtj.tmp/
*.jar
*.war
*.nar
*.ear
*.zip
*.tar.gz
*.rar
hs_err_pid*
target/
pom.xml.tag
pom.xml.releaseBackup
pom.xml.versionsBackup
pom.xml.next
release.properties
dependency-reduced-pom.xml
buildNumber.properties
.mvn/timing.properties
.mvn/wrapper/maven-wrapper.jar

A  => LICENSE +21 -0
@@ 1,21 @@
MIT License

Copyright (c) 2021 Michael Captain

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

A  => README.md +10 -0
@@ 1,10 @@
# 👻 Ghostspeller 👻

A command line application for
[Ghost](https://en.wikipedia.org/wiki/Ghost_(game)), the spelling game.

## TODO

- [ ] Add subcommand which prints out an optimal strategy with target words
- [ ] Allow for a variable number of players
- [ ] Add ghost variants

A  => ghostspeller.iml +52 -0
@@ 1,52 @@
<?xml version="1.0" encoding="UTF-8"?>
<module org.jetbrains.idea.maven.project.MavenProjectsManager.isMavenModule="true" type="JAVA_MODULE" version="4">
  <component name="FacetManager">
    <facet type="kotlin-language" name="Kotlin">
      <configuration version="3" platform="JVM 11" allPlatforms="JVM [11]" useProjectSettings="false">
        <compilerSettings />
        <compilerArguments>
          <option name="jvmTarget" value="11" />
          <option name="languageVersion" value="1.4" />
          <option name="apiVersion" value="1.4" />
          <option name="pluginOptions">
            <array />
          </option>
          <option name="pluginClasspaths">
            <array />
          </option>
          <option name="errors">
            <ArgumentParseErrors />
          </option>
        </compilerArguments>
      </configuration>
    </facet>
  </component>
  <component name="NewModuleRootManager" LANGUAGE_LEVEL="JDK_1_5">
    <output url="file://$MODULE_DIR$/target/classes" />
    <output-test url="file://$MODULE_DIR$/target/test-classes" />
    <content url="file://$MODULE_DIR$">
      <sourceFolder url="file://$MODULE_DIR$/src/main" isTestSource="false" />
      <sourceFolder url="file://$MODULE_DIR$/src/test" isTestSource="true" />
      <excludeFolder url="file://$MODULE_DIR$/target" />
    </content>
    <orderEntry type="inheritedJdk" />
    <orderEntry type="sourceFolder" forTests="false" />
    <orderEntry type="library" scope="TEST" name="Maven: org.jetbrains.kotlin:kotlin-test-junit5:1.4.21" level="project" />
    <orderEntry type="library" scope="TEST" name="Maven: org.jetbrains.kotlin:kotlin-test-annotations-common:1.4.21" level="project" />
    <orderEntry type="library" scope="TEST" name="Maven: org.jetbrains.kotlin:kotlin-test:1.4.21" level="project" />
    <orderEntry type="library" scope="TEST" name="Maven: org.jetbrains.kotlin:kotlin-test-common:1.4.21" level="project" />
    <orderEntry type="library" scope="TEST" name="Maven: org.junit.jupiter:junit-jupiter-api:5.6.0" level="project" />
    <orderEntry type="library" scope="TEST" name="Maven: org.apiguardian:apiguardian-api:1.1.0" level="project" />
    <orderEntry type="library" scope="TEST" name="Maven: org.opentest4j:opentest4j:1.2.0" level="project" />
    <orderEntry type="library" scope="TEST" name="Maven: org.junit.platform:junit-platform-commons:1.6.0" level="project" />
    <orderEntry type="library" scope="TEST" name="Maven: org.junit.jupiter:junit-jupiter-engine:5.6.0" level="project" />
    <orderEntry type="library" scope="TEST" name="Maven: org.junit.platform:junit-platform-engine:1.6.0" level="project" />
    <orderEntry type="library" name="Maven: org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.4.21" level="project" />
    <orderEntry type="library" name="Maven: org.jetbrains.kotlin:kotlin-stdlib:1.4.21" level="project" />
    <orderEntry type="library" name="Maven: org.jetbrains:annotations:13.0" level="project" />
    <orderEntry type="library" name="Maven: org.jetbrains.kotlin:kotlin-stdlib-jdk7:1.4.21" level="project" />
    <orderEntry type="library" scope="PROVIDED" name="Maven: org.graalvm.sdk:graal-sdk:19.3.4" level="project" />
    <orderEntry type="library" name="Maven: com.github.ajalt.clikt:clikt-jvm:3.1.0" level="project" />
    <orderEntry type="library" name="Maven: org.jetbrains.kotlin:kotlin-stdlib-common:1.4.21" level="project" />
  </component>
</module>
\ No newline at end of file

A  => pom.xml +123 -0
@@ 1,123 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xmlns="http://maven.apache.org/POM/4.0.0"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>

    <artifactId>ghostspeller</artifactId>
    <groupId>com.macaptain</groupId>
    <version>0.1.0</version>
    <packaging>jar</packaging>

    <name>ghostspeller</name>

    <properties>
        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
        <kotlin.code.style>official</kotlin.code.style>
        <kotlin.compiler.jvmTarget>11</kotlin.compiler.jvmTarget>
        <kotlin.version>1.4.21</kotlin.version>
        <graalvm.version>19.3.4</graalvm.version>
    </properties>

    <repositories>
        <repository>
            <id>mavenCentral</id>
            <url>https://repo1.maven.org/maven2/</url>
        </repository>
    </repositories>

    <build>
        <sourceDirectory>src/main</sourceDirectory>
        <testSourceDirectory>src/test</testSourceDirectory>
        <plugins>
            <plugin>
                <groupId>org.jetbrains.kotlin</groupId>
                <artifactId>kotlin-maven-plugin</artifactId>
                <version>${kotlin.version}</version>
                <executions>
                    <execution>
                        <id>compile</id>
                        <phase>compile</phase>
                        <goals>
                            <goal>compile</goal>
                        </goals>
                    </execution>
                    <execution>
                        <id>test-compile</id>
                        <phase>test-compile</phase>
                        <goals>
                            <goal>test-compile</goal>
                        </goals>
                    </execution>
                </executions>
            </plugin>
            <plugin>
                <artifactId>maven-surefire-plugin</artifactId>
                <version>2.22.2</version>
            </plugin>
            <plugin>
                <artifactId>maven-failsafe-plugin</artifactId>
                <version>2.22.2</version>
            </plugin>
            <plugin>
                <groupId>org.graalvm.nativeimage</groupId>
                <artifactId>native-image-maven-plugin</artifactId>
                <version>${graalvm.version}</version>
                <executions>
                    <execution>
                        <goals>
                            <goal>native-image</goal>
                        </goals>
                        <phase>package</phase>
                    </execution>
                </executions>
                <configuration>
                    <skip>false</skip>
                    <imageName>ghostspeller</imageName>
                    <mainClass>MainKt</mainClass>
                    <buildArgs>
                        --no-fallback
                        -H:ReflectionConfigurationFiles=classes/META-INF/reflection-config.json
                    </buildArgs>
                </configuration>
            </plugin>
        </plugins>
    </build>

    <dependencies>
        <dependency>
            <groupId>org.jetbrains.kotlin</groupId>
            <artifactId>kotlin-test-junit5</artifactId>
            <version>${kotlin.version}</version>
            <scope>test</scope>
        </dependency>
        <dependency>
            <groupId>org.junit.jupiter</groupId>
            <artifactId>junit-jupiter-api</artifactId>
            <version>5.6.0</version>
            <scope>test</scope>
        </dependency>
        <dependency>
            <groupId>org.junit.jupiter</groupId>
            <artifactId>junit-jupiter-engine</artifactId>
            <version>5.6.0</version>
            <scope>test</scope>
        </dependency>
        <dependency>
            <groupId>org.jetbrains.kotlin</groupId>
            <artifactId>kotlin-stdlib-jdk8</artifactId>
            <version>${kotlin.version}</version>
        </dependency>
        <dependency>
            <groupId>org.graalvm.sdk</groupId>
            <artifactId>graal-sdk</artifactId>
            <version>${graalvm.version}</version>
            <scope>provided</scope>
        </dependency>
        <dependency>
            <groupId>com.github.ajalt.clikt</groupId>
            <artifactId>clikt-jvm</artifactId>
            <version>3.1.0</version>
        </dependency>
    </dependencies>
</project>

A  => src/main/GhostTrie.kt +72 -0
@@ 1,72 @@
/**
 * A trie node representing a word or partial word with possible continuations of the word as children.
 *
 * @property s spelling of the word so far
 */
class GhostTrieNode(val s: String) {
    private val _children = mutableMapOf<Char, GhostTrieNode>()
    val children: Map<Char, GhostTrieNode> = _children
    var isWord = false
        private set

    /**
     * Returns true if and only if you can guarantee moves to be played on your turn that win you the game if you say
     * the string associated with this node on your turn.
     */
    val isWinning: Boolean by lazy {
        when {
            isWord -> false
            children.values.all { it.isWord } -> true
            else -> !children.values.any { it.isWinning }
        }
    }
    val winningChildren by lazy { children.filter { (_, node) -> node.isWinning } }
    val notWordChildren by lazy { children.filter { (_, node) -> !node.isWord } }

    fun child(c: Char) = children[c]!!

    companion object {
        /**
         * From word list
         *
         * @param wordList list of words considered valid.
         * @param minWordLength words must be at least this long to be considered words in the game of Ghost.
         * @param stem a partial word from which to start the spelling from. This becomes the root of the trie.
         * @return the root node of the trie.
         */
        fun fromWordList(wordList: Collection<String>, minWordLength: Int, stem: String = ""): GhostTrieNode {
            val root = GhostTrieNode(stem)
            val nodes = mutableMapOf(stem to root)
            wordList.filter { it.startsWith(stem) }.forEach { s ->
                var lastNode = root
                spellFrom(s, stem).forEach { subS ->
                    val node = nodes.getOrPut(subS, { GhostTrieNode(subS) })
                    lastNode._children[subS.last()] = node
                    lastNode = node
                }
                if (s.length >= minWordLength) nodes[s]!!.isWord = true
            }
            return root
        }
    }
}

/**
 * Spell out a word.
 * e.g. spell("foo") == listOf("f", "fo", "foo")
 *
 * @param s string to spell
 * @return list of strings which spell out the word one letter at a time
 */
fun spell(s: String) = s.indices.reversed().map { s.dropLast(it) }

/**
 * Spell a word from an initial stem. The spelling does not include the stem itself.
 * e.g. spell("foobar", "foo") == listOf("foob", "fooba", "foobar")
 *
 * @param s string to spell
 * @param stem if [s] starts with this, spell letters only after this prefix
 * @return list of strings which spell out the word from the given stem one letter at a time
 */
fun spellFrom(s: String, stem: String) = spell(s.removePrefix(stem)).map { stem + it }


A  => src/main/Main.kt +120 -0
@@ 1,120 @@
import com.github.ajalt.clikt.core.CliktCommand
import com.github.ajalt.clikt.core.subcommands
import com.github.ajalt.clikt.parameters.options.default
import com.github.ajalt.clikt.parameters.options.flag
import com.github.ajalt.clikt.parameters.options.option
import com.github.ajalt.clikt.parameters.options.versionOption
import com.github.ajalt.clikt.parameters.types.file
import com.github.ajalt.clikt.parameters.types.int
import java.io.File

const val GHOSTSPELLER_VERSION = "0.1.0"

class Ghostspeller : CliktCommand() {
    override fun run() = Unit
}

/**
 * Command line subcommand for pitting the player against the computer at Ghost.
 */
class Play : CliktCommand() {
    // Command line options
    private val dictionary by option(
        "-d",
        "--dictionary",
        help = "Path to a new-line separated list of words to be used as the word list (default /usr/share/dict/words)"
    )
        .file()
        .default(File("/usr/share/dict/words"))
    private val minWordLength by option(
        "-m",
        "--min-word-length",
        help = "Minimum word length for a word to be considered in the dictionary (default 4)"
    )
        .int()
        .default(4)
    private val initialStem by option(
        "-i",
        "--initial-stem",
        help = "Start the game midway through a word"
    ).default("")
    private val excludeProperNouns by option(
        "--use-proper-nouns",
        help = "Use words in the dictionary that begin with a capital letter (default false)"
    ).flag(default = false)

    /**
     * Starts the Ghost play loop in which the player and computer take turns spelling.
     */
    override fun run() {
        echo("\uD83D\uDC7B Play Ghost! \uD83D\uDC7B")
        print("Loading word list... ")
        val wordList = dictionary.readLines()
            .filter { it.isNotEmpty() }
            .filter { excludeProperNouns || it.first().isLowerCase() }
            .map { it.toLowerCase() }
            .toSet()
        val trie = GhostTrieNode.fromWordList(wordList, minWordLength, initialStem)
        var node = trie
        echo("Done!")
        while (true) {
            echo("\uD83E\uDE84 Spell! \uD83E\uDE84")
            if (initialStem.isNotEmpty()) echo(initialStem)
            while (true) {
                val input = readLine() ?: break
                if (input == "") continue
                if (input.count() > 1) {
                    echo("Spell one letter at at time!")
                    continue
                }

                val c = input.first()
                if (node.children.containsKey(c)) {
                    node = node.children[c]!!
                } else {
                    echo("There are no words beginning \"${node.s + c}\" in the dictionary. You lose!")
                    echo("Words beginning with \"${node.s}\":")
                    wordList.filter { it.startsWith(node.s) }.forEach {
                        echo("$it - https://www.merriam-webster.com/dictionary/$it")
                    }
                    break
                }

                if (node.isWord) {
                    echo("The word \"${node.s}\" is in the dictionary! You lose!")
                    echo("Meaning: https://www.merriam-webster.com/dictionary/${node.s}")
                    break
                } else {
                    if (node.s.count() > 1) echo(node.s) // reminder of word so far

                    node = when {
                        node.winningChildren.isNotEmpty() -> node.winningChildren.values.random()
                        node.notWordChildren.isNotEmpty() -> node.notWordChildren.values.random()
                        else -> node.children.values.random()
                    }
                }
                if (node.isWord) {
                    echo("Congratulations! You win with \"${node.s}\".")
                    echo("https://www.merriam-webster.com/dictionary/${node.s}")
                    break
                } else {
                    echo(node.s)
                }
            }
            echo("Play again? (Ctrl + C to quit)")
            readLine()
            node = trie
        }
    }
}

/**
 * Entrypoint for running ghostspeller.
 *
 * @param args command line arguments passed to the Clikt command.
 */
fun main(args: Array<String>) =
    Ghostspeller()
        .versionOption(GHOSTSPELLER_VERSION, names = setOf("-v", "--version"))
        .subcommands(Play())
        .main(args)

A  => src/main/resources/META-INF/reflection-config.json +11 -0
@@ 1,11 @@
[
  {
    "name": "kotlin.internal.jdk8.JDK8PlatformImplementations",
    "allDeclaredConstructors": true,
    "allPublicConstructors": true,
    "allDeclaredMethods": true,
    "allPublicMethods": true,
    "allDeclaredFields": true,
    "allPublicFields": true
  }
]

A  => src/test/GhostTrieKtTest.kt +51 -0
@@ 1,51 @@
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Test

internal class GhostTrieKtTest {

    @Test
    fun testSpell() {
        assertEquals(listOf("f", "fo", "foo", "foob", "fooba", "foobar"), spell("foobar"))
    }

    @Test
    fun testSpellFromStem() {
        assertEquals(listOf("foob", "fooba", "foobar"), spellFrom("foobar", "foo"))
    }

    @Test
    fun testWordListToTrie() {
        val wordList = listOf("a", "ab", "abc", "bde")
        val trie = GhostTrieNode.fromWordList(wordList, minWordLength = 0)

        assertEquals(setOf('a', 'b'), trie.children.keys)

        val aNode = trie.child('a')
        val bNode = trie.child('b')
        assertEquals(setOf('b'), aNode.children.keys)
        assertEquals(setOf('d'), bNode.children.keys)
        assert(!trie.isWord)
        assert(aNode.isWord)
        assert(!bNode.isWord)
    }

    @Test
    fun testNodeIsWinning() {
        val wordList = listOf("gopher", "gopar")
        val trie = GhostTrieNode.fromWordList(wordList, minWordLength = 4)
        val goNode = trie
            .child('g')
            .child('o')
        val gopNode = goNode.child('p')
        val gophNode = gopNode.child('h')
        val gopheNode = gophNode.child('e')
        val gopherNode = gopheNode.child('r')

        assert(!gopherNode.isWinning)
        assert(gopheNode.isWinning)
        assert(!gophNode.isWinning)
        assert(!gopNode.isWinning)
        assert(goNode.isWinning)
        assert(trie.isWinning) // so first player wins with this word list
    }
}