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
+ }
+}